initial commit

This commit is contained in:
Marius Rometsch
2026-02-05 22:42:30 +01:00
commit 0068785924
66 changed files with 13795 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# Multi-stage build for backend
# Builder stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Production stage
FROM node:18-alpine
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
# Copy dependencies from builder
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
# Copy application code
COPY --chown=nodejs:nodejs . .
# Switch to non-root user
USER nodejs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Start application
CMD ["node", "src/index.js"]
+930
View File
@@ -0,0 +1,930 @@
# Security Awareness Lessons Documentation
This document provides comprehensive information about all available lessons in the platform, including learning objectives, content structure, interactive components, and implementation details.
---
## Table of Contents
1. [Phishing Email Detection Basics](#1-phishing-email-detection-basics)
2. [SQL Injection Attack - Online Shop Demo](#2-sql-injection-attack---online-shop-demo)
3. [Browser-in-the-Browser (BitB) Attack](#3-browser-in-the-browser-bitb-attack)
4. [Creating New Lessons](#creating-new-lessons)
---
## 1. Phishing Email Detection Basics
### Overview
**Lesson Key:** `phishing-email-basics`
**Difficulty:** Beginner
**Duration:** 15 minutes
**Category:** Social Engineering / Email Security
### Learning Objectives
By the end of this lesson, participants will be able to:
- Identify common characteristics of phishing emails
- Recognize suspicious sender addresses and domains
- Detect urgency tactics used by attackers
- Understand link verification techniques
- Apply best practices for handling suspicious emails
### Content Structure
#### Steps:
1. **Introduction to Phishing** (Content)
- Definition and impact of phishing
- Statistics on phishing attacks
- Why email is a primary attack vector
2. **Common Red Flags** (Content)
- Suspicious sender addresses
- Spelling and grammar errors
- Urgent or threatening language
- Requests for sensitive information
- Suspicious links and attachments
3. **Question 1: Identify Phishing Indicators** (Multiple Choice)
- **Points:** 50 total
- **Correct Answers:**
- Misspelled sender domain (15 pts)
- Generic greeting instead of name (10 pts)
- Urgent threat about account closure (15 pts)
- Suspicious link destination (10 pts)
- **Topic:** Recognition of multiple warning signs
4. **Email Analysis Techniques** (Content)
- How to inspect sender information
- Hovering over links to check destinations
- Checking email headers
- Verifying legitimacy through official channels
5. **Question 2: Safe Email Practices** (Single Choice)
- **Points:** 25 total
- **Correct Answer:** "Hover over links to verify destination before clicking"
- **Topic:** Proactive defense techniques
6. **Question 3: Reporting Procedures** (Free Text)
- **Points:** 25 total
- **Validation:** Must mention "report", "IT", and "forward"
- **Topic:** Organizational response to phishing
### Scoring
- **Total Points:** 100
- **Passing Score:** 70%
- **Question Distribution:**
- Multiple choice: 50 points (partial credit)
- Single choice: 25 points (all or nothing)
- Free text: 25 points (keyword-based)
### Implementation Details
**Files:**
- Config: `backend/lessons/configs/phishing-email-basics.yaml`
- Module: `backend/lessons/modules/phishing-email-basics/index.js`
**Question Types:**
- Uses standard base class validation
- No custom interactive components
- Text-based content delivery
### Best Practices for Teaching
- Start with real-world examples
- Show actual phishing emails (sanitized)
- Emphasize the cost of successful attacks
- Practice with interactive email analysis
- Reinforce reporting procedures
---
## 2. SQL Injection Attack - Online Shop Demo
### Overview
**Lesson Key:** `sql-injection-shop`
**Difficulty:** Intermediate
**Duration:** 20 minutes
**Category:** Web Application Security / OWASP Top 10
### Learning Objectives
By the end of this lesson, participants will be able to:
- Understand how SQL injection vulnerabilities work
- Recognize vulnerable code patterns
- Execute SQL injection attacks in a safe environment
- Understand the difference between vulnerable and secure queries
- Apply parameterized queries as the primary defense
### Content Structure
#### Steps:
1. **What is SQL Injection?** (Content)
- Definition and mechanism
- Types of damage (data theft, modification, deletion)
- Authentication bypass techniques
- Administrative operation exploitation
2. **Vulnerable Online Shop** (Interactive)
- **Component:** `SQLShopDemo`
- Live product search with vulnerable SQL backend
- Real-time query visualization
- Injection detection and explanation
3. **Question 1: Identify SQL Injection Payloads** (Multiple Choice)
- **Points:** 40 total
- **Correct Answers:**
- `' OR '1'='1` (15 pts) - Always-true condition
- `' UNION SELECT username, password FROM users--` (15 pts) - Data extraction
- `'; DROP TABLE products--` (10 pts) - Destructive attack
- **Topic:** Recognition of injection syntax
4. **How SQL Injection Works** (Content)
- Query structure explanation
- Normal vs malicious input comparison
- Step-by-step breakdown of attacks
- Impact demonstration
5. **Question 2: Prevention Methods** (Single Choice)
- **Points:** 30 total
- **Correct Answer:** "Use parameterized queries (prepared statements)"
- **Topic:** Gold-standard defense mechanism
6. **Preventing SQL Injection** (Content)
- Parameterized queries (primary defense)
- Input validation strategies
- Least privilege principle
- Web Application Firewalls
- Security auditing
7. **Question 3: Explain Parameterized Queries** (Free Text)
- **Points:** 30 total
- **Validation:** Must mention "parameter", "data", and "separate"
- **Minimum Length:** 50 characters
- **Topic:** Understanding separation of code and data
### Interactive Component: SQLShopDemo
#### Features:
**Mock Database:**
```javascript
products: 8 items (laptops, accessories, office supplies)
users: 3 accounts (admin, john_doe, jane_smith)
orders: 2 sample orders
```
**Attack Scenarios:**
1. **OR Injection:**
- Input: `' OR '1'='1`
- Result: Returns ALL products
- Explanation: Bypasses WHERE clause with always-true condition
2. **UNION SELECT:**
- Input: `' UNION SELECT id, username, password, role, 'LEAKED' FROM users--`
- Result: Displays user credentials in product table
- Explanation: Combines product data with user table
3. **DROP TABLE:**
- Input: `'; DROP TABLE products--`
- Result: Simulates table deletion
- Explanation: Executes destructive SQL command
**UI Elements:**
- Search input with monospace font for code clarity
- "Vulnerable Search" button (red) - executes unsafe query
- "Safe Search" button (green) - uses parameterized query
- Quick-load example buttons
- Real-time SQL query display with syntax highlighting
- Injection detection warnings with emoji indicators
- Results table showing affected data
- Color-coded feedback (red for exploits, green for safe)
#### Technical Implementation:
**Backend Methods:**
```javascript
executeVulnerableQuery(searchTerm)
- Simulates vulnerable string concatenation
- Detects injection patterns
- Returns appropriate results based on attack type
executeSafeQuery(searchTerm)
- Demonstrates parameterized approach
- Treats all input as literal data
- Shows query with placeholder syntax
detectInjection(input)
- Regex-based pattern matching
- Identifies quotes, comments, SQL keywords
analyzeInjection(input)
- Classifies injection type
- Generates educational explanation
```
**Frontend API Call:**
```javascript
participantAPI.executeLessonAction(
eventLessonId,
'execute-query',
{ searchTerm, mode: 'vulnerable' | 'safe' }
)
```
### Scoring
- **Total Points:** 100
- **Passing Score:** 70%
- **Question Distribution:**
- Multiple choice: 40 points (partial credit)
- Single choice: 30 points
- Free text: 30 points (keyword validation)
### Implementation Details
**Files:**
- Config: `backend/lessons/configs/sql-injection-shop.yaml`
- Module: `backend/lessons/modules/sql-injection-shop/index.js`
- Component: `frontend/src/components/lessons/InteractiveContent/SQLShopDemo.jsx`
**Dependencies:**
- Extends `LessonModule` base class
- Custom `executeVulnerableQuery` method
- Custom `executeSafeQuery` method
- Interactive data provider
**API Endpoint:**
- `POST /api/lesson/:eventLessonId/action/execute-query`
- Requires participant authentication
- Validates lesson is started
### Best Practices for Teaching
- Start with normal searches to establish baseline
- Progress from simple to complex injections
- Always compare vulnerable vs safe implementations
- Emphasize that filtering alone is insufficient
- Show real-world impact examples
- Demonstrate UNION attacks to highlight data exposure risk
- Use color coding to make injection obvious
- Provide immediate feedback on each attempt
### Real-World Context
**OWASP Ranking:** #3 in OWASP Top 10 (Injection)
**Notable Incidents:**
- 2019: British Airways breach (380,000 transactions)
- 2020: Freepik SQL injection (8.3 million accounts)
- Ongoing: Automated scanning for vulnerable endpoints
**Industry Standards:**
- OWASP recommends parameterized queries
- PCI DSS requires SQL injection prevention
- ISO 27001 covers secure coding practices
---
## 3. Browser-in-the-Browser (BitB) Attack
### Overview
**Lesson Key:** `browser-in-browser-attack`
**Difficulty:** Advanced
**Duration:** 25 minutes
**Category:** Social Engineering / Advanced Phishing
### Learning Objectives
By the end of this lesson, participants will be able to:
- Understand Browser-in-the-Browser attack methodology
- Differentiate between real and fake browser windows
- Apply physical testing techniques to detect fake popups
- Recognize OAuth/SSO popup security implications
- Understand why password managers provide protection
### Content Structure
#### Steps:
1. **What is Browser-in-the-Browser?** (Content)
- Definition and attack mechanism
- Why it's effective (mimics trusted UI)
- Comparison to traditional phishing
- Historical context (2022 emergence)
2. **How the Attack Works** (Content)
- Traditional OAuth flow diagram
- BitB attack flow comparison
- Technical explanation (HTML/CSS fake browser)
- Visual deception techniques
3. **Interactive BitB Demo** (Interactive)
- **Component:** `BitBDemo`
- Side-by-side real vs fake comparison
- Interactive detection testing
- Real-world attack examples
4. **Question 1: Detection Indicators** (Multiple Choice)
- **Points:** 40 total
- **Correct Answers:**
- "Window cannot be dragged outside browser" (20 pts)
- "Right-click shows 'Inspect Element' on address bar" (20 pts)
- **Topic:** Physical behavior testing
5. **Detection Techniques** (Content)
- Drag window test (primary method)
- Address bar selectability check
- Right-click inspection test
- Pixel-perfect detail examination
- Taskbar appearance verification
- Browser extension usage
6. **Question 2: Safest Approach** (Single Choice)
- **Points:** 35 total
- **Correct Answer:** "Try to drag the popup outside browser window"
- **Topic:** Practical defense technique
7. **Protecting Against BitB** (Content)
- User defenses (testing, 2FA, manual navigation)
- Developer responsibilities (education, redirect flow)
- Organizational measures (training, hardware keys)
8. **Question 3: Password Manager Protection** (Free Text)
- **Points:** 25 total
- **Validation:** Must mention "domain", "autofill", and "real"
- **Minimum Length:** 40 characters
- **Topic:** Technical security controls
### Interactive Component: BitBDemo
#### Features:
**Two Scenarios:**
1. **Legitimate OAuth Popup (Simulated Real)**
- Provider: Google
- Domain: `accounts.google.com`
- Indicators shown for educational purposes
- Green "✅ REAL" badge
2. **BitB Attack (Fake Popup)**
- Provider: Microsoft
- Domain: `login.microsoftonline.com`
- Trapped within page boundaries
- Red "⚠️ FAKE" badge
**Interactive Tests:**
1. **Drag Test:**
- Real: Would allow dragging (simulated)
- Fake: Cannot drag outside browser
- Feedback: Yellow warning appears when attempted
2. **Right-Click Test:**
- Real: Browser context menu (simulated)
- Fake: Shows "Inspect Element" menu
- Feedback: Warning about HTML detection
3. **Visual Inspection:**
- Fake window controls (non-functional buttons)
- Fake address bar (styled HTML div)
- Fake HTTPS lock icon (just an image)
**UI Elements:**
- Side-by-side scenario cards
- Provider-specific styling (Google blue, Microsoft blue)
- Launch buttons to open fake popups
- Dark overlay when popup is active
- Educational indicators list
- Real-world attack timeline
- Test instructions panel
**Realistic Browser Chrome:**
```
- macOS-style window controls (red, yellow, green)
- Address bar with lock icon
- Provider-specific branding
- Login form (email + password)
- Sign-in button
```
#### Technical Implementation:
**Frontend Structure:**
```javascript
renderFakeBrowser(scenario)
- Creates modal overlay
- Renders fake browser window
- Applies provider styling
- Handles drag attempts
- Handles right-click detection
- Shows feedback badges
```
**Drag Detection:**
```javascript
handleDragStart(e, isReal)
- Sets dragAttempted flag
- Prevents drag if fake (e.preventDefault)
- Shows educational feedback
```
**Right-Click Detection:**
```javascript
handleAddressBarRightClick(e, isReal)
- Sets inspectAttempted flag
- Allows context menu on fake popup
- Shows educational feedback
```
**Real-World Examples Data:**
```javascript
[
{ year: 2022, target: 'Corporate employees', provider: 'Microsoft OAuth' },
{ year: 2022, target: 'Cryptocurrency users', provider: 'Google Sign-in' },
{ year: 2023, target: 'GitHub developers', provider: 'GitHub OAuth' }
]
```
### Scoring
- **Total Points:** 100
- **Passing Score:** 75%
- **Question Distribution:**
- Multiple choice: 40 points (physical testing)
- Single choice: 35 points (best practice)
- Free text: 25 points (technical understanding)
### Implementation Details
**Files:**
- Config: `backend/lessons/configs/browser-in-browser-attack.yaml`
- Module: `backend/lessons/modules/browser-in-browser-attack/index.js`
- Component: `frontend/src/components/lessons/InteractiveContent/BitBDemo.jsx`
**Dependencies:**
- Extends `LessonModule` base class
- Custom `getInteractiveData` method
- React state management for popups
- CSS-in-JS for fake browser styling
**Special Features:**
- Modal overlay system
- Drag prevention
- Context menu detection
- Provider theming
- Responsive design
### Best Practices for Teaching
- Emphasize that visual inspection alone is insufficient
- Demonstrate the drag test as the most reliable method
- Show how convincing the fake popups can be
- Discuss password manager benefits
- Explain why manual navigation is safest
- Reference real-world incidents
- Practice detection multiple times
- Warn about new variations
### Real-World Context
**Discovery:** 2022 by security researcher mr.d0x
**Attack Campaign Examples:**
- **March 2022:** Steam account phishing
- **April 2022:** Cryptocurrency exchange targeting
- **May 2022:** Corporate credential harvesting
- **2023:** GitHub and GitLab developer targeting
**Affected Platforms:**
- Any OAuth/SSO provider (Google, Microsoft, Facebook, GitHub)
- Banking sites with "secure" login popups
- Enterprise SSO systems
- Cryptocurrency wallets
**Why It's Effective:**
- Mimics trusted UI perfectly
- Bypasses traditional phishing training
- Works on security-aware users
- No browser warnings triggered
- HTTPS indicators can be faked
**Defense Evolution:**
- Hardware security keys (FIDO2/WebAuthn) immune
- Password managers check real domain
- Browser extensions can detect fake UI
- User education most critical
---
## Creating New Lessons
### Overview
This section provides guidance for creating new lessons in the platform.
### Lesson Structure
Every lesson consists of two main components:
1. **YAML Configuration File** (`lessons/configs/*.yaml`)
- Defines lesson metadata
- Structures content steps
- Configures questions and answers
- Sets scoring rules
2. **JavaScript Module** (`lessons/modules/*/index.js`)
- Extends `LessonModule` base class
- Implements custom validation logic
- Provides interactive data
- Handles special behaviors
### YAML Configuration Format
```yaml
lessonKey: "unique-lesson-identifier"
title: "Lesson Display Title"
description: "Brief description for lesson catalog"
difficultyLevel: "beginner|intermediate|advanced"
estimatedDuration: 15 # minutes
module: "module-directory-name"
steps:
- id: "step-1"
type: "content|question|interactive"
title: "Step Title"
content: "Step content (can be multiline)"
- id: "question-1"
type: "question"
questionType: "single_choice|multiple_choice|free_text"
question: "The question text?"
options: # for choice questions
- id: "option-1"
text: "Option text"
isCorrect: true|false
points: 10
validationRules: # for free_text questions
keywords:
required: ["keyword1", "keyword2"]
partialCredit: 5
minLength: 50
maxPoints: 25
feedback:
correct: "Positive feedback"
incorrect: "Educational feedback"
scoring:
passingScore: 70
maxTotalPoints: 100
```
### JavaScript Module Structure
```javascript
const LessonModule = require('../base/LessonModule');
class YourLessonModule extends LessonModule {
constructor(config) {
super(config);
}
// Optional: Custom answer validation
async validateAnswer(questionId, answer) {
// Custom logic here, or use:
return super.validateAnswer(questionId, answer);
}
// Optional: Provide data for interactive components
getInteractiveData(stepId) {
if (stepId === 'your-interactive-step') {
return {
// Data your frontend component needs
};
}
return null;
}
// Optional: Custom methods for lesson-specific actions
yourCustomMethod(params) {
// Implementation
}
}
module.exports = YourLessonModule;
```
### Question Types
#### 1. Single Choice
```yaml
questionType: "single_choice"
options:
- id: "correct-option"
text: "The right answer"
isCorrect: true
points: 25
- id: "wrong-option"
text: "An incorrect answer"
isCorrect: false
points: 0
```
- Only one correct answer
- All-or-nothing scoring
- Radio button UI
#### 2. Multiple Choice
```yaml
questionType: "multiple_choice"
options:
- id: "correct-1"
text: "First correct answer"
isCorrect: true
points: 15
- id: "correct-2"
text: "Second correct answer"
isCorrect: true
points: 15
- id: "wrong-1"
text: "Incorrect answer"
isCorrect: false
points: 0
```
- Multiple correct answers
- Partial credit awarded per correct selection
- Checkbox UI
#### 3. Free Text
```yaml
questionType: "free_text"
validationRules:
keywords:
required: ["must", "contain", "these"]
partialCredit: 10 # points if some keywords present
minLength: 50 # minimum character count
maxPoints: 25
```
- Open-ended response
- Keyword-based validation
- Minimum length requirement
### Interactive Components
#### Creating a New Interactive Component
1. **Define in YAML:**
```yaml
- id: "interactive-demo"
type: "interactive"
title: "Interactive Demo"
interactiveComponent: "YourComponentName"
content: "Instructions for the interactive element"
```
2. **Provide Data in Module:**
```javascript
getInteractiveData(stepId) {
if (stepId === 'interactive-demo') {
return {
data: 'Your component data',
config: {}
};
}
return null;
}
```
3. **Create React Component:**
```javascript
// frontend/src/components/lessons/InteractiveContent/YourComponent.jsx
import React from 'react';
const YourComponent = ({ lessonData, eventLessonId }) => {
const interactiveData = lessonData?.interactiveData || {};
return (
<div>
{/* Your interactive UI */}
</div>
);
};
export default YourComponent;
```
4. **Register in LessonView:**
```javascript
// frontend/src/pages/LessonView.jsx
import YourComponent from '../components/lessons/InteractiveContent/YourComponent';
// In render:
{currentStep.interactiveComponent === 'YourComponent' && (
<YourComponent lessonData={lesson} eventLessonId={eventLessonId} />
)}
```
### Lesson-Specific Actions
For interactive components that need backend processing:
1. **Add Method to Module:**
```javascript
yourCustomAction(params) {
// Process params
return result;
}
```
2. **Handle in Controller:**
```javascript
// backend/src/controllers/lesson.controller.js
if (action === 'your-action' && lessonModule.yourCustomAction) {
result = lessonModule.yourCustomAction(actionData);
}
```
3. **Call from Frontend:**
```javascript
participantAPI.executeLessonAction(
eventLessonId,
'your-action',
{ data }
)
```
### Seeding New Lessons
1. **Add to Catalog:**
```javascript
// backend/seed-lessons.js
const lessons = [
{
lesson_key: 'your-lesson-key',
title: 'Your Lesson Title',
description: 'Description',
module_path: 'your-module-directory',
config_path: 'your-config.yaml',
difficulty_level: 'intermediate',
estimated_duration: 20
}
];
```
2. **Run Seed Script:**
```bash
docker exec lernplattform_backend node seed-lessons.js
```
### Best Practices
**Content Design:**
- Start with clear learning objectives
- Use progressive difficulty (easy → hard)
- Provide immediate feedback
- Include real-world examples
- Make it hands-on when possible
**Question Design:**
- Multiple choice: 2-4 options, avoid "all of the above"
- Free text: Clear validation criteria
- Feedback: Educational, not just correct/incorrect
- Points: Reflect question difficulty
**Interactive Elements:**
- Make them essential, not decorative
- Provide clear instructions
- Give immediate visual feedback
- Include educational explanations
- Allow experimentation
**Code Quality:**
- Follow existing patterns
- Handle errors gracefully
- Validate all inputs
- Comment complex logic
- Test thoroughly
**Security:**
- Never execute actual SQL
- Sandbox all demonstrations
- Validate on both frontend and backend
- Don't leak sensitive information
- Use appropriate warnings
### Testing New Lessons
1. **Lesson Content:**
- All steps render correctly
- Images/media load properly
- Text formatting is correct
2. **Questions:**
- Correct answers award proper points
- Wrong answers give appropriate feedback
- Partial credit calculates correctly
- Free text validation works
3. **Interactive Components:**
- Components load without errors
- Actions execute successfully
- Feedback displays correctly
- Edge cases handled
4. **Scoring:**
- Total points sum correctly
- Passing threshold works
- Score persists properly
- Leaderboard updates
5. **Progress:**
- Lesson marked as started
- Navigation works (prev/next)
- Completion triggers properly
- Locked lessons stay locked
### File Checklist
- [ ] YAML config created
- [ ] Module directory created
- [ ] Module class implemented
- [ ] Interactive components (if any) created
- [ ] Seed script updated
- [ ] Lesson seeded to database
- [ ] Tested in browser
- [ ] Documentation updated
---
## Appendix
### Lesson Difficulty Guidelines
**Beginner:**
- Foundational concepts
- No prior security knowledge required
- 10-15 minute duration
- Basic terminology introduction
- Real-world relevance emphasized
**Intermediate:**
- Builds on basic security awareness
- Requires understanding of systems/networks
- 15-25 minute duration
- Hands-on demonstrations
- Technical explanations
**Advanced:**
- Assumes security knowledge
- Complex attack scenarios
- 20-30 minute duration
- In-depth technical details
- Sophisticated defenses
### Scoring Philosophy
- **Partial Credit:** Encourage learning, reward partial knowledge
- **Passing Score:** 70-75% allows mistakes while ensuring competency
- **Question Weight:** Harder questions = more points
- **Immediate Feedback:** Don't wait until end of lesson
### Content Style Guide
**Tone:**
- Professional but approachable
- Educational, not preachy
- Objective about risks
- Encouraging about defenses
**Formatting:**
- Use bullet points for lists
- Bold important terms on first use
- Code blocks for technical content
- Clear step-by-step instructions
**Examples:**
- Prefer real-world incidents
- Include dates and sources
- Show impact (financial, reputational)
- Demonstrate both attacks and defenses
---
## Support
For questions about lesson development:
- Review existing lessons as templates
- Check base class methods in `LessonModule.js`
- Test in development environment first
- Document any new patterns
**Last Updated:** 2026-01-12
**Platform Version:** 1.0.0
**Total Lessons:** 3
+310
View File
@@ -0,0 +1,310 @@
# Lessons Directory
This directory contains all lesson content for the security awareness training platform.
## Structure
```
lessons/
├── configs/ # YAML lesson configurations
│ ├── phishing-email-basics.yaml
│ ├── sql-injection-shop.yaml
│ └── browser-in-browser-attack.yaml
├── modules/ # JavaScript lesson modules
│ ├── base/
│ │ └── LessonModule.js # Base class all lessons extend
│ ├── phishing-email-basics/
│ │ └── index.js
│ ├── sql-injection-shop/
│ │ └── index.js
│ └── browser-in-browser-attack/
│ └── index.js
├── lesson-schema.json # JSON schema for validation (optional)
├── README.md # This file
└── LESSONS_DOCUMENTATION.md # Comprehensive lesson docs
```
## Available Lessons
### 1. Phishing Email Detection Basics
- **Key:** `phishing-email-basics`
- **Difficulty:** Beginner
- **Duration:** 15 minutes
- **Topics:** Email security, social engineering, red flags
- **Interactive:** No
### 2. SQL Injection Attack - Online Shop Demo
- **Key:** `sql-injection-shop`
- **Difficulty:** Intermediate
- **Duration:** 20 minutes
- **Topics:** Web security, OWASP Top 10, SQL injection
- **Interactive:** Yes - Fake shop with vulnerable search
### 3. Browser-in-the-Browser (BitB) Attack
- **Key:** `browser-in-browser-attack`
- **Difficulty:** Advanced
- **Duration:** 25 minutes
- **Topics:** Advanced phishing, OAuth security, UI spoofing
- **Interactive:** Yes - Fake browser popup demos
## Quick Start
### Adding a New Lesson
1. **Create YAML config:**
```bash
cp configs/phishing-email-basics.yaml configs/your-lesson.yaml
# Edit the file with your lesson content
```
2. **Create module:**
```bash
mkdir modules/your-lesson
# Create index.js extending LessonModule
```
3. **Seed to database:**
```bash
# Add to seed script
docker exec lernplattform_backend node seed-lessons.js
```
4. **Assign to event:**
- Use admin panel to assign lesson to events
- Configure points, weight, and order
### Testing a Lesson
1. **Seed the lesson** (see above)
2. **Assign to a test event** via admin panel
3. **Join event as participant** from hub page
4. **Complete the lesson** and verify:
- All steps render correctly
- Questions award proper points
- Interactive components work
- Score calculates correctly
## Lesson Configuration Format
### Minimal YAML Example
```yaml
lessonKey: "my-lesson"
title: "My Lesson Title"
description: "Brief description"
difficultyLevel: "beginner"
estimatedDuration: 15
module: "my-lesson"
steps:
- id: "intro"
type: "content"
title: "Introduction"
content: "Lesson content here..."
- id: "q1"
type: "question"
questionType: "single_choice"
question: "What is the answer?"
options:
- id: "correct"
text: "The right answer"
isCorrect: true
points: 100
- id: "wrong"
text: "Wrong answer"
isCorrect: false
points: 0
maxPoints: 100
feedback:
correct: "Great job!"
incorrect: "Try again!"
scoring:
passingScore: 70
maxTotalPoints: 100
```
### Minimal Module Example
```javascript
const LessonModule = require('../base/LessonModule');
class MyLesson extends LessonModule {
constructor(config) {
super(config);
}
// Use base class validation by default
// Override only if custom logic needed
}
module.exports = MyLesson;
```
## Question Types
### Single Choice
- One correct answer
- Radio buttons in UI
- All-or-nothing scoring
### Multiple Choice
- Multiple correct answers
- Checkboxes in UI
- Partial credit per correct selection
### Free Text
- Open-ended response
- Keyword-based validation
- Minimum length requirement
## Interactive Components
For lessons with interactive demos:
1. **Define in YAML:**
```yaml
- id: "demo"
type: "interactive"
title: "Interactive Demo"
interactiveComponent: "MyComponent"
```
2. **Provide data in module:**
```javascript
getInteractiveData(stepId) {
if (stepId === 'demo') {
return { /* component data */ };
}
return null;
}
```
3. **Create React component:**
```
frontend/src/components/lessons/InteractiveContent/MyComponent.jsx
```
4. **Register in LessonView.jsx**
## File Naming Conventions
- **Lesson keys:** lowercase-with-hyphens
- **Config files:** `{lesson-key}.yaml`
- **Module directories:** `{lesson-key}/`
- **Module entry:** `index.js`
## Best Practices
### Content
- Start with clear learning objectives
- Use real-world examples
- Progress from easy to hard
- Provide immediate feedback
- Make it hands-on when possible
### Questions
- 2-4 options for choice questions
- Clear, unambiguous wording
- Educational feedback (not just "correct"/"incorrect")
- Points reflect difficulty
### Code
- Extend `LessonModule` base class
- Use base class methods when possible
- Comment complex logic
- Handle errors gracefully
- Validate all inputs
### Security
- Never execute actual dangerous commands
- Sandbox all demonstrations
- Use appropriate warnings
- Don't leak sensitive data
## Common Patterns
### Multi-step Content Lesson
```yaml
steps:
- type: content (intro)
- type: content (main content)
- type: question
- type: content (summary)
- type: question
```
### Interactive Demo Lesson
```yaml
steps:
- type: content (intro)
- type: interactive (hands-on)
- type: question (about demo)
- type: content (explanation)
- type: question (best practices)
```
### Progressive Learning
```yaml
# Start easy
- Single choice with obvious answer
# Build complexity
- Multiple choice with several correct answers
# Test understanding
- Free text requiring explanation
```
## Troubleshooting
### Lesson not appearing in admin panel
- Check if lesson was seeded to database
- Verify `lesson_key` matches between YAML and database
- Check database logs for errors
### Questions not scoring correctly
- Verify `maxPoints` matches sum of correct option points
- For multiple choice, ensure points are on correct options
- Check `isCorrect` boolean values
### Interactive component not loading
- Verify component name matches in YAML
- Check component is imported in LessonView.jsx
- Look for console errors in browser
- Verify `getInteractiveData()` returns data
### Module not found errors
- Check `module_path` in database matches directory name
- Verify `index.js` exists in module directory
- Ensure `module.exports` is present
- Check for syntax errors in module
## Documentation
- **Comprehensive Guide:** [LESSONS_DOCUMENTATION.md](./LESSONS_DOCUMENTATION.md)
- **Base Class:** [modules/base/LessonModule.js](./modules/base/LessonModule.js)
- **Examples:** See existing lessons in `configs/` and `modules/`
## Development Workflow
1. **Design** lesson content and questions
2. **Create** YAML config and module
3. **Test** locally with seed script
4. **Assign** to test event
5. **Validate** all questions and interactions
6. **Review** scoring and feedback
7. **Deploy** to production event
## Support
For questions or issues:
1. Review existing lesson implementations
2. Check base class documentation
3. Test in development environment first
4. Review error logs for debugging
---
**Last Updated:** 2026-01-12
**Total Lessons:** 3
**Platform Version:** 1.0.0
@@ -0,0 +1,174 @@
lessonKey: "browser-in-browser-attack"
title: "Browser-in-the-Browser (BitB) Attack"
description: "Learn to identify sophisticated phishing attacks that mimic legitimate browser windows"
difficultyLevel: "advanced"
estimatedDuration: 25
module: "browser-in-browser-attack"
steps:
- id: "intro"
type: "content"
title: "What is Browser-in-the-Browser?"
content: |
Browser-in-the-Browser (BitB) is an advanced phishing technique that creates a fake browser window inside a webpage. It's designed to trick users into thinking they're interacting with a legitimate OAuth/SSO login popup.
Why it's dangerous:
• Looks identical to real browser popup windows
• Shows a fake address bar with HTTPS lock icon
• Mimics trusted services (Google, Microsoft, Facebook)
• Can steal credentials even from security-aware users
• Bypasses traditional phishing detection
This attack gained prominence in 2022 and has been used in targeted attacks against organizations.
- id: "how-it-works"
type: "content"
title: "How the Attack Works"
content: |
Traditional OAuth Flow:
1. User clicks "Sign in with Google" on a website
2. Browser opens a REAL popup to google.com
3. User enters credentials on Google's actual site
4. Google redirects back with authentication token
BitB Attack Flow:
1. User clicks "Sign in with Google" on malicious site
2. Site creates a FAKE popup using HTML/CSS/JavaScript
3. Fake popup shows fake address bar displaying "accounts.google.com"
4. User enters credentials on attacker's fake page
5. Attacker captures credentials and simulates success
The entire "browser window" is actually just HTML elements styled to look like a browser!
- id: "bitb-demo"
type: "interactive"
title: "Interactive BitB Demo"
interactiveComponent: "BitBDemo"
content: |
Below you'll see two login scenarios. One uses a REAL browser popup (secure), and one uses a BitB attack (malicious).
Can you identify the fake? Pay close attention to the details!
- id: "question-1"
type: "question"
questionType: "multiple_choice"
question: "What are the key indicators that can help identify a Browser-in-the-Browser attack?"
options:
- id: "https-lock"
text: "The presence of HTTPS and a lock icon in the address bar"
isCorrect: false
points: 0
- id: "window-behavior"
text: "The popup window cannot be dragged outside the main browser window"
isCorrect: true
points: 20
- id: "inspect-element"
text: "Right-clicking allows you to 'Inspect Element' on the address bar"
isCorrect: true
points: 20
- id: "domain-name"
text: "The domain name shown in the address bar"
isCorrect: false
points: 0
maxPoints: 40
feedback:
correct: "Excellent! Real browser windows can be moved anywhere and their UI cannot be inspected as HTML elements."
incorrect: "Think about what differentiates a real browser window from HTML/CSS elements on a webpage. The lock icon and domain can both be faked!"
- id: "detection-techniques"
type: "content"
title: "Detecting BitB Attacks"
content: |
How to spot a Browser-in-the-Browser attack:
1. **Try to Drag the Window**
• Real popups can be dragged outside the browser
• Fake popups are trapped within the main window
2. **Check if Address Bar is Selectable**
• Real address bars: text is selectable
• Fake address bars: usually just an image or styled div
3. **Right-Click the Address Bar**
• Real browser: no "Inspect Element" option
• Fake browser: shows HTML inspection menu
4. **Look for Pixel-Perfect Details**
• Fake windows may have slight styling differences
• Shadow effects, fonts, or spacing might be off
5. **Check Your Browser's Task Bar**
• Real popups appear as separate windows in taskbar
• Fake popups don't create new window entries
6. **Use Browser Extensions**
• Some extensions can detect fake browser UI
- id: "question-2"
type: "question"
questionType: "single_choice"
question: "A website asks you to 'Sign in with Microsoft' and a popup appears. What is the SAFEST approach?"
options:
- id: "trust-https"
text: "Check for HTTPS in the address bar and proceed if present"
isCorrect: false
points: 0
- id: "test-window"
text: "Try to drag the popup outside the browser window to verify it's real"
isCorrect: true
points: 35
- id: "check-domain"
text: "Carefully read the domain name to ensure it's Microsoft's real domain"
isCorrect: false
points: 0
- id: "close-and-manual"
text: "Close the popup and manually navigate to Microsoft's site"
isCorrect: false
points: 10
maxPoints: 35
feedback:
correct: "Perfect! Testing if the window can be dragged outside the browser is the most reliable quick check. Though manually navigating is also very safe!"
incorrect: "While checking the domain helps, it can be faked in a BitB attack. The physical behavior of the window (can it be dragged out?) reveals the truth."
- id: "prevention"
type: "content"
title: "Protecting Against BitB Attacks"
content: |
For Users:
• Always test if popup windows can be moved freely
• Use password managers (they check actual domains)
• Enable 2FA/MFA for additional security layer
• Be suspicious of unexpected login prompts
• Manually navigate to sites instead of clicking links
For Developers:
• Educate users about OAuth popup behavior
• Use OAuth redirect flow instead of popups when possible
• Implement additional verification steps
• Consider passwordless authentication methods
• Show clear security indicators in your app
For Organizations:
• Train employees to recognize advanced phishing
• Deploy anti-phishing browser extensions
• Use hardware security keys (FIDO2/WebAuthn)
• Monitor for suspicious authentication attempts
• Implement conditional access policies
- id: "question-3"
type: "question"
questionType: "free_text"
question: "Why are password managers particularly effective at protecting against BitB attacks?"
validationRules:
keywords:
required: ["domain", "autofill", "real"]
partialCredit: 8
minLength: 40
maxPoints: 25
feedback:
correct: "Excellent! Password managers check the actual domain of the page and won't autofill credentials on fake domains, even if they look legitimate."
incorrect: "Think about how password managers verify which site they're on before filling in credentials. They check the real URL, not what's displayed visually."
scoring:
passingScore: 75
maxTotalPoints: 100
@@ -0,0 +1,117 @@
lessonKey: "phishing-email-basics"
title: "Phishing Email Detection Basics"
description: "Learn to identify common phishing tactics in emails and protect yourself from email-based attacks"
difficultyLevel: "beginner"
estimatedDuration: 15
module: "phishing-email-basics"
steps:
- id: "intro"
type: "content"
title: "What is Phishing?"
content: |
Phishing is a type of cyber attack where attackers impersonate legitimate organizations
to steal sensitive information like passwords, credit card numbers, or personal data.
Phishing emails often:
- Create a sense of urgency
- Contain suspicious links or attachments
- Have spelling and grammar errors
- Use generic greetings like "Dear Customer"
- Request sensitive information
- id: "example-1"
type: "content"
title: "Example Phishing Email"
content: |
**From:** security@paypa1-verify.com
**Subject:** Urgent: Verify Your Account Now!
Dear Valued Customer,
Your PayPal account has been temporarily suspended due to unusual activity.
To restore your account, please verify your information immediately by clicking
the link below:
[Verify Account Now]
Failure to verify within 24 hours will result in permanent account suspension.
Thank you,
PayPal Security Team
- id: "question-1"
type: "question"
questionType: "multiple_choice"
question: "What are the suspicious elements in this email? (Select all that apply)"
options:
- id: "misspelled-domain"
text: "The sender's domain is misspelled (paypa1 instead of paypal)"
isCorrect: true
points: 15
- id: "urgent-language"
text: "Uses urgent/threatening language to create pressure"
isCorrect: true
points: 15
- id: "generic-greeting"
text: "Uses generic greeting 'Dear Valued Customer'"
isCorrect: true
points: 10
- id: "requests-action"
text: "Requests immediate action via a link"
isCorrect: true
points: 10
- id: "legitimate"
text: "This appears to be a legitimate email"
isCorrect: false
points: 0
maxPoints: 50
feedback:
correct: "Excellent! You identified all the key phishing indicators."
partial: "Good job! You spotted some red flags, but review the email again carefully."
incorrect: "Not quite. Let's review the common signs of phishing emails."
- id: "question-2"
type: "question"
questionType: "single_choice"
question: "What should you do if you receive a suspicious email like this?"
options:
- id: "click-link"
text: "Click the link to verify my account"
isCorrect: false
points: 0
- id: "reply-email"
text: "Reply to the email asking if it's legitimate"
isCorrect: false
points: 0
- id: "delete-report"
text: "Delete the email and report it as phishing"
isCorrect: true
points: 25
- id: "forward-friends"
text: "Forward it to friends to warn them"
isCorrect: false
points: 0
maxPoints: 25
feedback:
correct: "Perfect! Deleting and reporting phishing emails is the right approach."
incorrect: "That's not safe. Never click links or reply to suspicious emails. Delete and report them."
- id: "question-3"
type: "question"
questionType: "free_text"
question: "Describe at least three things you should check before clicking a link in an email."
validationRules:
- type: "contains_keywords"
keywords: ["sender", "domain", "url", "link", "https", "hover", "address", "spelling", "grammar"]
minMatches: 3
- type: "min_length"
value: 50
maxPoints: 25
feedback:
correct: "Great answer! You understand the importance of verifying emails before taking action."
incorrect: "Consider checking the sender's email address, hovering over links to see the real URL, and looking for HTTPS."
scoring:
passingScore: 70
maxTotalPoints: 100
@@ -0,0 +1,143 @@
lessonKey: "sql-injection-shop"
title: "SQL Injection Attack - Online Shop Demo"
description: "Learn how SQL injection vulnerabilities work through a realistic online shop scenario"
difficultyLevel: "intermediate"
estimatedDuration: 20
module: "sql-injection-shop"
steps:
- id: "intro"
type: "content"
title: "What is SQL Injection?"
content: |
SQL Injection is one of the most dangerous web application vulnerabilities. It occurs when an attacker can insert malicious SQL code into a query, allowing them to:
• Access unauthorized data
• Modify or delete database records
• Bypass authentication
• Execute administrative operations
In this lesson, you'll explore a vulnerable online shop to understand how SQL injection works and why proper input validation is critical.
- id: "shop-demo"
type: "interactive"
title: "Vulnerable Online Shop"
interactiveComponent: "SQLShopDemo"
content: |
Below is a simplified online shop with a product search feature. The search functionality is vulnerable to SQL injection.
Try searching for normal products first, then experiment with SQL injection techniques.
- id: "question-1"
type: "question"
questionType: "multiple_choice"
question: "Which of the following search inputs could be used to exploit SQL injection?"
options:
- id: "normal-search"
text: "laptop"
isCorrect: false
points: 0
- id: "single-quote"
text: "' OR '1'='1"
isCorrect: true
points: 15
- id: "union-select"
text: "' UNION SELECT username, password FROM users--"
isCorrect: true
points: 15
- id: "drop-table"
text: "'; DROP TABLE products--"
isCorrect: true
points: 10
maxPoints: 40
feedback:
correct: "Correct! These inputs manipulate the SQL query structure."
incorrect: "Review the demo. SQL injection exploits use special characters like quotes and SQL keywords."
- id: "detection"
type: "content"
title: "How SQL Injection Works"
content: |
A vulnerable query might look like:
SELECT * FROM products WHERE name LIKE '%[USER_INPUT]%'
When a user searches for "laptop", the query becomes:
SELECT * FROM products WHERE name LIKE '%laptop%'
But if they enter "' OR '1'='1", it becomes:
SELECT * FROM products WHERE name LIKE '%' OR '1'='1%'
The OR '1'='1' condition is always true, so ALL products are returned!
More dangerous attacks can extract data from other tables or even delete data.
- id: "question-2"
type: "question"
questionType: "single_choice"
question: "What is the BEST way to prevent SQL injection vulnerabilities?"
options:
- id: "input-filtering"
text: "Filter out dangerous characters like quotes and semicolons"
isCorrect: false
points: 0
- id: "parameterized-queries"
text: "Use parameterized queries (prepared statements)"
isCorrect: true
points: 30
- id: "stored-procedures"
text: "Only use stored procedures for database access"
isCorrect: false
points: 0
- id: "input-length"
text: "Limit the length of user inputs"
isCorrect: false
points: 0
maxPoints: 30
feedback:
correct: "Excellent! Parameterized queries separate SQL code from user data, making injection impossible."
incorrect: "While filtering helps, parameterized queries are the gold standard. They ensure user input is always treated as data, never as SQL code."
- id: "mitigation"
type: "content"
title: "Preventing SQL Injection"
content: |
Best practices to prevent SQL injection:
1. **Parameterized Queries** (Most Important)
• Use prepared statements with bound parameters
• Never concatenate user input into SQL strings
2. **Input Validation**
• Validate data types (numbers, emails, etc.)
• Use allowlists for expected values
3. **Least Privilege**
• Database accounts should have minimal permissions
• Read-only accounts for read operations
4. **Web Application Firewalls**
• Can detect and block SQL injection attempts
• Should be used as an additional layer, not primary defense
5. **Regular Security Audits**
• Code reviews and penetration testing
• Automated vulnerability scanning
- id: "question-3"
type: "question"
questionType: "free_text"
question: "In your own words, explain why parameterized queries prevent SQL injection."
validationRules:
keywords:
required: ["parameter", "data", "separate"]
partialCredit: 10
minLength: 50
maxPoints: 30
feedback:
correct: "Great explanation! You understand that parameterized queries keep SQL structure separate from user data."
incorrect: "Think about how parameterized queries treat user input differently than string concatenation. Key concepts: separation of code and data."
scoring:
passingScore: 70
maxTotalPoints: 100
@@ -0,0 +1,230 @@
/**
* Base class for all lesson modules
* All lesson modules should extend this class
*/
class LessonModule {
constructor(config) {
this.config = config;
this.lessonKey = config.lessonKey;
}
/**
* Validate an answer for a specific question
* @param {string} questionId - The question identifier
* @param {any} answer - The participant's answer
* @returns {Object} { isCorrect, pointsAwarded, feedback }
*/
async validateAnswer(questionId, answer) {
const step = this.config.steps.find(s => s.id === questionId);
if (!step || step.type !== 'question') {
throw new Error(`Question ${questionId} not found`);
}
return this._validateQuestionType(step, answer);
}
/**
* Internal validation based on question type
*/
_validateQuestionType(step, answer) {
switch (step.questionType) {
case 'single_choice':
return this._validateSingleChoice(step, answer);
case 'multiple_choice':
return this._validateMultipleChoice(step, answer);
case 'free_text':
return this._validateFreeText(step, answer);
default:
throw new Error(`Unknown question type: ${step.questionType}`);
}
}
_validateSingleChoice(step, answer) {
const selectedOption = step.options.find(opt => opt.id === answer);
if (!selectedOption) {
return {
isCorrect: false,
pointsAwarded: 0,
feedback: step.feedback?.incorrect || 'Incorrect answer'
};
}
return {
isCorrect: selectedOption.isCorrect,
pointsAwarded: selectedOption.isCorrect ? selectedOption.points : 0,
feedback: selectedOption.isCorrect
? (step.feedback?.correct || 'Correct!')
: (step.feedback?.incorrect || 'Incorrect answer')
};
}
_validateMultipleChoice(step, answers) {
// answers should be an array of option IDs
if (!Array.isArray(answers)) {
return {
isCorrect: false,
pointsAwarded: 0,
feedback: step.feedback?.incorrect || 'Invalid answer format'
};
}
const correctOptions = step.options.filter(opt => opt.isCorrect).map(opt => opt.id);
const selectedCorrect = answers.filter(a => correctOptions.includes(a));
const selectedIncorrect = answers.filter(a => !correctOptions.includes(a));
// Calculate points
const pointsAwarded = selectedCorrect.reduce((sum, id) => {
const option = step.options.find(opt => opt.id === id);
return sum + (option?.points || 0);
}, 0);
const isFullyCorrect = selectedCorrect.length === correctOptions.length &&
selectedIncorrect.length === 0;
const isPartiallyCorrect = selectedCorrect.length > 0 && !isFullyCorrect;
let feedback = step.feedback?.incorrect || 'Incorrect answer';
if (isFullyCorrect) {
feedback = step.feedback?.correct || 'Correct!';
} else if (isPartiallyCorrect) {
feedback = step.feedback?.partial || step.feedback?.correct || 'Partially correct';
}
return {
isCorrect: isFullyCorrect,
isPartial: isPartiallyCorrect,
pointsAwarded,
feedback
};
}
_validateFreeText(step, answer) {
if (!answer || typeof answer !== 'string') {
return {
isCorrect: false,
pointsAwarded: 0,
feedback: step.feedback?.incorrect || 'Answer is required'
};
}
if (!step.validationRules || step.validationRules.length === 0) {
// No validation rules, accept any non-empty answer
const points = answer.trim().length > 0 ? step.maxPoints : 0;
return {
isCorrect: points > 0,
pointsAwarded: points,
feedback: points > 0
? (step.feedback?.correct || 'Answer received')
: (step.feedback?.incorrect || 'Answer is too short')
};
}
let passedRules = 0;
const totalRules = step.validationRules.length;
for (const rule of step.validationRules) {
if (this._checkValidationRule(rule, answer)) {
passedRules++;
}
}
const scorePercentage = passedRules / totalRules;
const pointsAwarded = Math.round(step.maxPoints * scorePercentage);
const isCorrect = scorePercentage >= 0.7; // 70% threshold
return {
isCorrect,
pointsAwarded,
feedback: isCorrect
? (step.feedback?.correct || 'Good answer!')
: (step.feedback?.incorrect || 'Please review your answer')
};
}
_checkValidationRule(rule, answer) {
const lowerAnswer = (answer || '').toLowerCase();
switch (rule.type) {
case 'contains_keywords':
const matches = rule.keywords.filter(keyword =>
lowerAnswer.includes(keyword.toLowerCase())
).length;
return matches >= (rule.minMatches || 1);
case 'min_length':
return answer.length >= rule.value;
case 'max_length':
return answer.length <= rule.value;
case 'regex':
return new RegExp(rule.pattern, rule.flags || 'i').test(answer);
default:
return false;
}
}
/**
* Get interactive component data for a step
* Can be overridden by subclasses for dynamic content
*/
async getInteractiveData(stepId) {
const step = this.config.steps.find(s => s.id === stepId);
if (!step || step.type !== 'interactive') {
throw new Error(`Interactive step ${stepId} not found`);
}
return {
component: step.interactiveComponent,
props: step.componentProps || {}
};
}
/**
* Get lesson content for rendering (without answers)
*/
getContent() {
return {
lessonKey: this.lessonKey,
title: this.config.title,
description: this.config.description,
difficultyLevel: this.config.difficultyLevel,
estimatedDuration: this.config.estimatedDuration,
steps: this.config.steps.map(step => ({
id: step.id,
type: step.type,
title: step.title,
content: step.content,
// For question steps, don't send correct answers
...(step.type === 'question' && {
questionType: step.questionType,
question: step.question,
maxPoints: step.maxPoints,
options: step.options?.map(opt => ({
id: opt.id,
text: opt.text
// isCorrect and points are intentionally omitted
}))
}),
// For interactive steps, send component info
...(step.type === 'interactive' && {
interactiveComponent: step.interactiveComponent,
componentProps: step.componentProps
})
})),
scoring: {
maxTotalPoints: this.config.scoring?.maxTotalPoints || 100,
passingScore: this.config.scoring?.passingScore || 70
}
};
}
/**
* Get full configuration (for debugging/admin)
*/
getFullConfig() {
return this.config;
}
}
module.exports = LessonModule;
@@ -0,0 +1,83 @@
const LessonModule = require('../base/LessonModule');
class BrowserInBrowserLesson extends LessonModule {
constructor(config) {
super(config);
}
// Get interactive data for the BitB demo
getInteractiveData(stepId) {
if (stepId === 'bitb-demo') {
return {
scenarios: [
{
id: 'legitimate',
title: 'Legitimate OAuth Popup',
provider: 'Google',
domain: 'accounts.google.com',
isReal: true,
description: 'This simulates how a REAL browser popup would behave',
indicators: [
'Can be dragged outside browser window',
'Has native window controls',
'Address bar text is not selectable (real browser UI)',
'Right-click shows browser context menu, not page menu',
'Appears as separate window in system taskbar'
]
},
{
id: 'bitb-attack',
title: 'Browser-in-the-Browser Attack',
provider: 'Microsoft',
domain: 'login.microsoftonline.com',
isReal: false,
description: 'This is a FAKE popup window created with HTML/CSS/JavaScript',
indicators: [
'Cannot be dragged outside the main browser window',
'Entire window is trapped within the page boundaries',
'Address bar is just HTML text/image (right-click shows Inspect)',
'Window controls (minimize, maximize, close) are fake buttons',
'Does not appear in system taskbar as separate window'
]
}
],
testInstructions: [
'Try to drag each popup window outside the main browser area',
'Right-click on the address bar to see if you can inspect it as HTML',
'Look for subtle differences in fonts, spacing, or shadows',
'Check if the window controls behave like real browser buttons',
'Notice if the popup can extend beyond the main window boundaries'
],
realWorldExamples: [
{
year: 2022,
target: 'Corporate employees',
provider: 'Microsoft OAuth',
description: 'Attackers used BitB to steal enterprise credentials'
},
{
year: 2022,
target: 'Cryptocurrency users',
provider: 'Google Sign-in',
description: 'Fake crypto platforms used BitB for account takeovers'
},
{
year: 2023,
target: 'GitHub developers',
provider: 'GitHub OAuth',
description: 'Malicious sites mimicked GitHub login to steal tokens'
}
]
};
}
return null;
}
// Validate specific BitB detection knowledge
async validateAnswer(questionId, answer) {
// Use base class validation for standard question types
return super.validateAnswer(questionId, answer);
}
}
module.exports = BrowserInBrowserLesson;
@@ -0,0 +1,16 @@
const LessonModule = require('../base/LessonModule');
/**
* Phishing Email Detection Basics Lesson
* Teaches participants to identify common phishing tactics
*/
class PhishingEmailBasicsLesson extends LessonModule {
constructor(config) {
super(config);
}
// This lesson uses the default validation from the base class
// No custom validation needed for this beginner lesson
}
module.exports = PhishingEmailBasicsLesson;
@@ -0,0 +1,209 @@
const LessonModule = require('../base/LessonModule');
class SQLInjectionShopLesson extends LessonModule {
constructor(config) {
super(config);
}
// Mock database with products
getMockDatabase() {
return {
products: [
{ id: 1, name: 'Laptop Pro 15', price: 1299.99, category: 'Electronics', stock: 15 },
{ id: 2, name: 'Wireless Mouse', price: 29.99, category: 'Accessories', stock: 50 },
{ id: 3, name: 'USB-C Cable', price: 12.99, category: 'Accessories', stock: 100 },
{ id: 4, name: 'Gaming Keyboard', price: 89.99, category: 'Electronics', stock: 25 },
{ id: 5, name: 'Monitor 27"', price: 349.99, category: 'Electronics', stock: 20 },
{ id: 6, name: 'Webcam HD', price: 79.99, category: 'Electronics', stock: 30 },
{ id: 7, name: 'Desk Lamp', price: 34.99, category: 'Office', stock: 40 },
{ id: 8, name: 'Notebook Set', price: 15.99, category: 'Office', stock: 60 }
],
users: [
{ id: 1, username: 'admin', password: 'hashed_admin_password', role: 'admin' },
{ id: 2, username: 'john_doe', password: 'hashed_user_password', role: 'customer' },
{ id: 3, username: 'jane_smith', password: 'hashed_user_password', role: 'customer' }
],
orders: [
{ id: 1, user_id: 2, total: 1329.98, status: 'shipped' },
{ id: 2, user_id: 3, total: 89.99, status: 'processing' }
]
};
}
// Simulate vulnerable SQL query
executeVulnerableQuery(searchTerm) {
const db = this.getMockDatabase();
// Build the "vulnerable" query string for educational display
const vulnerableQuery = `SELECT * FROM products WHERE name LIKE '%${searchTerm}%'`;
// Detect SQL injection attempts
const injectionDetected = this.detectInjection(searchTerm);
let results = [];
let injectionType = null;
let explanation = '';
if (injectionDetected) {
const injectionInfo = this.analyzeInjection(searchTerm);
injectionType = injectionInfo.type;
explanation = injectionInfo.explanation;
// Simulate different injection results
if (injectionInfo.type === 'OR_ALWAYS_TRUE') {
// Return all products (simulating OR '1'='1')
results = db.products;
} else if (injectionInfo.type === 'UNION_SELECT') {
// Simulate UNION attack showing user data
results = [
{ id: 'INJECTED', name: 'admin', price: 'hashed_admin_password', category: 'LEAKED DATA', stock: 'admin' },
{ id: 'INJECTED', name: 'john_doe', price: 'hashed_user_password', category: 'LEAKED DATA', stock: 'customer' },
{ id: 'INJECTED', name: 'jane_smith', price: 'hashed_user_password', category: 'LEAKED DATA', stock: 'customer' }
];
} else if (injectionInfo.type === 'DROP_TABLE') {
// Simulate destructive command
results = [];
explanation += ' In a real scenario, this could delete the entire products table!';
} else if (injectionInfo.type === 'COMMENT_INJECTION') {
// Bypass rest of query
results = db.products;
}
} else {
// Normal search - filter products by name
results = db.products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return {
query: vulnerableQuery,
results,
injectionDetected,
injectionType,
explanation,
recordCount: results.length
};
}
// Detect if input contains SQL injection
detectInjection(input) {
const injectionPatterns = [
/'/, // Single quote
/--/, // SQL comment
/;/, // Statement separator
/union/i, // UNION keyword
/select/i, // SELECT keyword
/drop/i, // DROP keyword
/insert/i, // INSERT keyword
/update/i, // UPDATE keyword
/delete/i, // DELETE keyword
/or\s+['"]?\d+['"]?\s*=\s*['"]?\d+['"]?/i // OR 1=1 pattern
];
return injectionPatterns.some(pattern => pattern.test(input));
}
// Analyze the type of SQL injection
analyzeInjection(input) {
const lowerInput = input.toLowerCase();
if (lowerInput.includes('union') && lowerInput.includes('select')) {
return {
type: 'UNION_SELECT',
explanation: '⚠️ UNION SELECT injection detected! This technique combines results from multiple tables, potentially exposing sensitive data like usernames and passwords.'
};
}
if (lowerInput.includes('drop')) {
return {
type: 'DROP_TABLE',
explanation: '🚨 DROP TABLE injection detected! This is a destructive attack that could delete entire database tables. Critical data loss would occur!'
};
}
if (lowerInput.includes("'") && (lowerInput.includes('or') || lowerInput.includes('||'))) {
if (lowerInput.match(/or\s+['"]?\d+['"]?\s*=\s*['"]?\d+['"]?/)) {
return {
type: 'OR_ALWAYS_TRUE',
explanation: "⚠️ OR injection detected! The condition '1'='1' is always true, bypassing the intended filter and returning ALL records."
};
}
}
if (lowerInput.includes('--') || lowerInput.includes('#')) {
return {
type: 'COMMENT_INJECTION',
explanation: '⚠️ Comment injection detected! The -- sequence comments out the rest of the SQL query, potentially bypassing security checks.'
};
}
if (lowerInput.includes(';')) {
return {
type: 'MULTIPLE_STATEMENTS',
explanation: '⚠️ Multiple statement injection detected! The semicolon allows execution of additional SQL commands, enabling complex attacks.'
};
}
// Generic injection
return {
type: 'GENERIC',
explanation: '⚠️ SQL injection attempt detected! Special characters in the input could manipulate the query structure.'
};
}
// Demonstrate safe parameterized query
executeSafeQuery(searchTerm) {
const db = this.getMockDatabase();
// Show the safe query with placeholder
const safeQuery = `SELECT * FROM products WHERE name LIKE ?`;
const parameter = `%${searchTerm}%`;
// Execute safe search (treats all input as literal data)
const results = db.products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return {
query: safeQuery,
parameter,
results,
explanation: '✅ Parameterized query used! User input is treated as data only, never as SQL code. Injection is impossible.',
recordCount: results.length
};
}
// Get interactive data for the SQL shop demo
getInteractiveData(stepId) {
if (stepId === 'shop-demo') {
return {
database: this.getMockDatabase(),
examples: [
{
label: 'Normal Search',
input: 'laptop',
description: 'Search for products containing "laptop"'
},
{
label: 'View All Products (OR injection)',
input: "' OR '1'='1",
description: 'Exploit: Returns all products by making condition always true'
},
{
label: 'Extract User Data (UNION)',
input: "' UNION SELECT id, username, password, role, 'LEAKED' FROM users--",
description: 'Exploit: Combines product results with user table data'
},
{
label: 'Destructive Attack (DROP)',
input: "'; DROP TABLE products--",
description: 'Exploit: Attempts to delete the products table'
}
]
};
}
return null;
}
}
module.exports = SQLInjectionShopLesson;
+2186
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "lernplattform-backend",
"version": "1.0.0",
"description": "Backend API for Security Awareness Learning Platform",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"security",
"learning",
"platform",
"education"
],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3",
"bcrypt": "^5.1.1",
"jsonwebtoken": "^9.0.2",
"js-yaml": "^4.1.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}
+78
View File
@@ -0,0 +1,78 @@
const { pool } = require('./src/config/database');
const lessons = [
{
lesson_key: 'sql-injection-shop',
title: 'SQL Injection Attack - Online Shop Demo',
description: 'Learn how SQL injection vulnerabilities work through a realistic online shop scenario',
module_path: 'sql-injection-shop',
config_path: 'sql-injection-shop.yaml',
difficulty_level: 'intermediate',
estimated_duration: 20
},
{
lesson_key: 'browser-in-browser-attack',
title: 'Browser-in-the-Browser (BitB) Attack',
description: 'Learn to identify sophisticated phishing attacks that mimic legitimate browser windows',
module_path: 'browser-in-browser-attack',
config_path: 'browser-in-browser-attack.yaml',
difficulty_level: 'advanced',
estimated_duration: 25
}
];
async function seedLessons() {
const client = await pool.connect();
try {
console.log('Starting to seed new lessons...');
for (const lesson of lessons) {
// Check if lesson already exists
const existingResult = await client.query(
'SELECT id FROM lessons WHERE lesson_key = $1',
[lesson.lesson_key]
);
if (existingResult.rows.length > 0) {
console.log(`Lesson "${lesson.lesson_key}" already exists, skipping...`);
continue;
}
// Insert new lesson
const result = await client.query(
`INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[
lesson.lesson_key,
lesson.title,
lesson.description,
lesson.module_path,
lesson.config_path,
lesson.difficulty_level,
lesson.estimated_duration
]
);
console.log(`✓ Created lesson: ${lesson.title} (ID: ${result.rows[0].id})`);
}
console.log('\n✅ All new lessons seeded successfully!');
console.log('\nYou can now assign these lessons to events via the admin panel.');
} catch (error) {
console.error('Error seeding lessons:', error);
throw error;
} finally {
client.release();
await pool.end();
}
}
seedLessons()
.then(() => process.exit(0))
.catch((error) => {
console.error('Seed failed:', error);
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
const { Pool } = require('pg');
const config = require('./environment');
// Create PostgreSQL connection pool
const pool = new Pool({
host: config.database.host,
port: config.database.port,
database: config.database.name,
user: config.database.user,
password: config.database.password,
max: 20, // Maximum number of clients in the pool
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Test connection
pool.on('connect', () => {
console.log('Database connected successfully');
});
pool.on('error', (err) => {
console.error('Unexpected database error:', err);
process.exit(-1);
});
// Query helper function
const query = async (text, params) => {
const start = Date.now();
try {
const res = await pool.query(text, params);
const duration = Date.now() - start;
console.log('Executed query', { text, duration, rows: res.rowCount });
return res;
} catch (error) {
console.error('Database query error:', error);
throw error;
}
};
// Transaction helper
const transaction = async (callback) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
module.exports = {
pool,
query,
transaction
};
+33
View File
@@ -0,0 +1,33 @@
require('dotenv').config();
module.exports = {
// Server configuration
nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '3000', 10),
// Database configuration
database: {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
name: process.env.DB_NAME || 'lernplattform',
user: process.env.DB_USER || 'lernplattform_user',
password: process.env.DB_PASSWORD || 'changeme123'
},
// Security configuration
jwtSecret: process.env.JWT_SECRET || 'change_this_secret_key_in_production',
sessionSecret: process.env.SESSION_SECRET || 'change_this_session_secret',
sessionTimeout: parseInt(process.env.SESSION_TIMEOUT || '3600000', 10), // 1 hour default
// Admin configuration
adminDefaultPassword: process.env.ADMIN_DEFAULT_PASSWORD || 'admin123',
// Logging configuration
logLevel: process.env.LOG_LEVEL || 'info',
// CORS configuration
corsOrigin: process.env.CORS_ORIGIN || '*',
// Paths
lessonsPath: process.env.LESSONS_PATH || './lessons'
};
@@ -0,0 +1,96 @@
const { ApiError } = require('../middleware/errorHandler');
const { generateAdminToken, verifyPassword } = require('../middleware/auth');
const db = require('../config/database');
/**
* Admin login
* POST /api/admin/login
*/
const login = async (req, res) => {
const { username, password } = req.body;
// Validate input
if (!username || !password) {
throw new ApiError(400, 'Username and password are required');
}
// Get admin from database
const result = await db.query(
'SELECT id, username, password_hash FROM admin_users WHERE username = $1',
[username]
);
if (result.rows.length === 0) {
throw new ApiError(401, 'Invalid credentials');
}
const admin = result.rows[0];
// Verify password
const isValidPassword = await verifyPassword(password, admin.password_hash);
if (!isValidPassword) {
throw new ApiError(401, 'Invalid credentials');
}
// Update last login timestamp
await db.query(
'UPDATE admin_users SET last_login = CURRENT_TIMESTAMP WHERE id = $1',
[admin.id]
);
// Generate token
const token = generateAdminToken(admin.id, admin.username);
res.json({
success: true,
message: 'Login successful',
data: {
token,
admin: {
id: admin.id,
username: admin.username
}
}
});
};
/**
* Get current admin profile
* GET /api/admin/profile
*/
const getProfile = async (req, res) => {
const result = await db.query(
'SELECT id, username, created_at, last_login FROM admin_users WHERE id = $1',
[req.admin.id]
);
if (result.rows.length === 0) {
throw new ApiError(404, 'Admin not found');
}
res.json({
success: true,
data: result.rows[0]
});
};
/**
* Verify token (for client-side token validation)
* GET /api/admin/verify
*/
const verifyToken = async (req, res) => {
res.json({
success: true,
message: 'Token is valid',
data: {
admin: req.admin
}
});
};
module.exports = {
login,
getProfile,
verifyToken
};
@@ -0,0 +1,156 @@
const { ApiError } = require('../middleware/errorHandler');
const lessonQueries = require('../models/queries/lesson.queries');
const eventQueries = require('../models/queries/event.queries');
/**
* Get all lessons
* GET /api/admin/lessons
*/
const getAllLessons = async (req, res) => {
const lessons = await lessonQueries.getAllLessons();
res.json({
success: true,
data: lessons
});
};
/**
* Assign lesson to event
* POST /api/admin/events/:eventId/lessons
*/
const assignLessonToEvent = async (req, res) => {
const { eventId } = req.params;
let { lessonId, orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId } = req.body;
// Validate required fields
if (!lessonId) {
throw new ApiError(400, 'Lesson ID is required');
}
// Check if event exists
const eventExists = await eventQueries.eventExists(eventId);
if (!eventExists) {
throw new ApiError(404, 'Event not found');
}
// Check if lesson exists
const lessonExists = await lessonQueries.lessonExists(lessonId);
if (!lessonExists) {
throw new ApiError(404, 'Lesson not found');
}
// Check if lesson is already assigned to this event
const existingLessons = await lessonQueries.getEventLessons(eventId);
const alreadyAssigned = existingLessons.find(el => el.lesson_id === parseInt(lessonId));
if (alreadyAssigned) {
throw new ApiError(400, 'This lesson is already assigned to this event');
}
// If no order index specified, calculate next available
if (orderIndex === undefined) {
const maxOrder = existingLessons.reduce((max, el) => Math.max(max, el.order_index), 0);
orderIndex = maxOrder + 1;
} else {
// Check if order index conflicts
const orderConflict = existingLessons.find(el => el.order_index === parseInt(orderIndex));
if (orderConflict) {
// Auto-increment to next available
const maxOrder = existingLessons.reduce((max, el) => Math.max(max, el.order_index), 0);
orderIndex = maxOrder + 1;
}
}
// Assign lesson to event
const eventLesson = await lessonQueries.assignLessonToEvent(
eventId,
lessonId,
orderIndex,
maxPoints || 100,
weight || 1.0,
isRequired !== undefined ? isRequired : true,
unlockAfterLessonId
);
res.status(201).json({
success: true,
message: 'Lesson assigned to event',
data: eventLesson
});
};
/**
* Get lessons assigned to an event
* GET /api/admin/events/:eventId/lessons
*/
const getEventLessons = async (req, res) => {
const { eventId } = req.params;
// Check if event exists
const eventExists = await eventQueries.eventExists(eventId);
if (!eventExists) {
throw new ApiError(404, 'Event not found');
}
const lessons = await lessonQueries.getEventLessons(eventId);
res.json({
success: true,
data: lessons
});
};
/**
* Update event lesson configuration
* PUT /api/admin/events/:eventId/lessons/:eventLessonId
*/
const updateEventLesson = async (req, res) => {
const { eventLessonId } = req.params;
const { orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId } = req.body;
const updates = {};
if (orderIndex !== undefined) updates.order_index = orderIndex;
if (maxPoints !== undefined) updates.max_points = maxPoints;
if (weight !== undefined) updates.weight = weight;
if (isRequired !== undefined) updates.is_required = isRequired;
if (unlockAfterLessonId !== undefined) updates.unlock_after_lesson_id = unlockAfterLessonId;
const updated = await lessonQueries.updateEventLesson(eventLessonId, updates);
if (!updated) {
throw new ApiError(404, 'Event lesson not found');
}
res.json({
success: true,
message: 'Event lesson updated',
data: updated
});
};
/**
* Remove lesson from event
* DELETE /api/admin/events/:eventId/lessons/:eventLessonId
*/
const removeEventLesson = async (req, res) => {
const { eventLessonId } = req.params;
const removed = await lessonQueries.removeEventLesson(eventLessonId);
if (!removed) {
throw new ApiError(404, 'Event lesson not found');
}
res.json({
success: true,
message: 'Lesson removed from event'
});
};
module.exports = {
getAllLessons,
assignLessonToEvent,
getEventLessons,
updateEventLesson,
removeEventLesson
};
+190
View File
@@ -0,0 +1,190 @@
const { ApiError } = require('../middleware/errorHandler');
const eventQueries = require('../models/queries/event.queries');
const participantQueries = require('../models/queries/participant.queries');
/**
* Create a new event
* POST /api/admin/events
*/
const createEvent = async (req, res) => {
const { name, description, startDate, endDate } = req.body;
// Validate input
if (!name) {
throw new ApiError(400, 'Event name is required');
}
if (name.length < 3 || name.length > 255) {
throw new ApiError(400, 'Event name must be between 3 and 255 characters');
}
// Validate dates if provided
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
if (end <= start) {
throw new ApiError(400, 'End date must be after start date');
}
}
const event = await eventQueries.createEvent(name, description, startDate, endDate);
res.status(201).json({
success: true,
message: 'Event created successfully',
data: event
});
};
/**
* Get all events
* GET /api/admin/events
*/
const getAllEvents = async (req, res) => {
const events = await eventQueries.getAllEvents();
res.json({
success: true,
data: events
});
};
/**
* Get event by ID
* GET /api/admin/events/:eventId
*/
const getEventById = async (req, res) => {
const { eventId } = req.params;
const event = await eventQueries.getEventById(eventId);
if (!event) {
throw new ApiError(404, 'Event not found');
}
res.json({
success: true,
data: event
});
};
/**
* Update event
* PUT /api/admin/events/:eventId
*/
const updateEvent = async (req, res) => {
const { eventId } = req.params;
const { name, description, startDate, endDate, isActive } = req.body;
// Check if event exists
const exists = await eventQueries.eventExists(eventId);
if (!exists) {
throw new ApiError(404, 'Event not found');
}
// Validate name if provided
if (name !== undefined) {
if (!name || name.length < 3 || name.length > 255) {
throw new ApiError(400, 'Event name must be between 3 and 255 characters');
}
}
// Validate dates if both provided
if (startDate && endDate) {
const start = new Date(startDate);
const end = new Date(endDate);
if (end <= start) {
throw new ApiError(400, 'End date must be after start date');
}
}
const updates = {};
if (name !== undefined) updates.name = name;
if (description !== undefined) updates.description = description;
if (startDate !== undefined) updates.start_date = startDate;
if (endDate !== undefined) updates.end_date = endDate;
if (isActive !== undefined) updates.is_active = isActive;
const updatedEvent = await eventQueries.updateEvent(eventId, updates);
res.json({
success: true,
message: 'Event updated successfully',
data: updatedEvent
});
};
/**
* Delete event
* DELETE /api/admin/events/:eventId
*/
const deleteEvent = async (req, res) => {
const { eventId } = req.params;
// Check if event exists
const exists = await eventQueries.eventExists(eventId);
if (!exists) {
throw new ApiError(404, 'Event not found');
}
await eventQueries.deleteEvent(eventId);
res.json({
success: true,
message: 'Event deleted successfully'
});
};
/**
* Get event participants
* GET /api/admin/events/:eventId/participants
*/
const getEventParticipants = async (req, res) => {
const { eventId } = req.params;
// Check if event exists
const exists = await eventQueries.eventExists(eventId);
if (!exists) {
throw new ApiError(404, 'Event not found');
}
const participants = await participantQueries.getParticipantsByEvent(eventId);
res.json({
success: true,
data: participants
});
};
/**
* Get event statistics
* GET /api/admin/events/:eventId/analytics
*/
const getEventAnalytics = async (req, res) => {
const { eventId } = req.params;
// Check if event exists
const exists = await eventQueries.eventExists(eventId);
if (!exists) {
throw new ApiError(404, 'Event not found');
}
const statistics = await eventQueries.getEventStatistics(eventId);
res.json({
success: true,
data: statistics
});
};
module.exports = {
createEvent,
getAllEvents,
getEventById,
updateEvent,
deleteEvent,
getEventParticipants,
getEventAnalytics
};
@@ -0,0 +1,287 @@
const { ApiError } = require('../middleware/errorHandler');
const lessonQueries = require('../models/queries/lesson.queries');
const progressQueries = require('../models/queries/progress.queries');
const lessonLoader = require('../services/lessonLoader.service');
const scoringService = require('../services/scoring.service');
/**
* Get lessons for an event (participant view)
* GET /api/participant/event/:eventId/lessons
*/
const getEventLessons = async (req, res) => {
const { eventId } = req.params;
const participantId = req.participant.id;
const lessons = await lessonQueries.getEventLessonsWithProgress(eventId, participantId);
res.json({
success: true,
data: lessons.map(lesson => ({
eventLessonId: lesson.event_lesson_id,
lessonId: lesson.id,
lessonKey: lesson.lesson_key,
title: lesson.title,
description: lesson.description,
difficultyLevel: lesson.difficulty_level,
estimatedDuration: lesson.estimated_duration,
orderIndex: lesson.order_index,
maxPoints: lesson.max_points,
weight: lesson.weight,
isRequired: lesson.is_required,
isUnlocked: lesson.is_unlocked,
progress: lesson.progress_id ? {
status: lesson.status,
score: lesson.score,
attempts: lesson.attempts,
startedAt: lesson.started_at,
completedAt: lesson.completed_at
} : null
}))
});
};
/**
* Get lesson content
* GET /api/participant/lesson/:eventLessonId
*/
const getLessonContent = async (req, res) => {
const { eventLessonId } = req.params;
const participantId = req.participant.id;
// Get event lesson details
const eventLesson = await lessonQueries.getEventLessonById(eventLessonId);
if (!eventLesson) {
throw new ApiError(404, 'Lesson not found');
}
// Check if lesson is unlocked
const isUnlocked = await progressQueries.isLessonUnlocked(participantId, eventLessonId);
if (!isUnlocked) {
throw new ApiError(403, 'This lesson is locked. Complete previous lessons first.');
}
// Load lesson content from module
const content = await lessonLoader.getLessonContent(eventLesson.lesson_key);
// Get progress if exists
const progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
res.json({
success: true,
data: {
eventLessonId,
...content,
maxPoints: eventLesson.max_points,
weight: eventLesson.weight,
progress: progress ? {
id: progress.id,
status: progress.status,
score: progress.score,
currentStep: progress.current_step,
attempts: progress.attempts
} : null
}
});
};
/**
* Start a lesson
* POST /api/participant/lesson/:eventLessonId/start
*/
const startLesson = async (req, res) => {
const { eventLessonId } = req.params;
const participantId = req.participant.id;
// Check if lesson is unlocked
const isUnlocked = await progressQueries.isLessonUnlocked(participantId, eventLessonId);
if (!isUnlocked) {
throw new ApiError(403, 'This lesson is locked');
}
// Start or resume progress
const progress = await progressQueries.startLesson(participantId, eventLessonId);
res.json({
success: true,
message: 'Lesson started',
data: {
progressId: progress.id,
status: progress.status,
startedAt: progress.started_at
}
});
};
/**
* Submit an answer
* POST /api/participant/lesson/:eventLessonId/answer
*/
const submitAnswer = async (req, res) => {
const { eventLessonId } = req.params;
const { questionId, answer } = req.body;
const participantId = req.participant.id;
if (!questionId || answer === undefined) {
throw new ApiError(400, 'Question ID and answer are required');
}
// Get event lesson
const eventLesson = await lessonQueries.getEventLessonById(eventLessonId);
if (!eventLesson) {
throw new ApiError(404, 'Lesson not found');
}
// Get or create progress
let progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
if (!progress) {
// Auto-start lesson if not started
progress = await progressQueries.startLesson(participantId, eventLessonId);
}
// Validate answer using lesson module
const validation = await lessonLoader.validateAnswer(
eventLesson.lesson_key,
questionId,
answer
);
// Save answer to database
await progressQueries.saveAnswer(
progress.id,
questionId,
answer,
validation.isCorrect,
validation.pointsAwarded,
validation.feedback
);
// Update score
const newScore = await progressQueries.updateScore(progress.id, validation.pointsAwarded);
res.json({
success: true,
data: {
isCorrect: validation.isCorrect,
isPartial: validation.isPartial || false,
pointsAwarded: validation.pointsAwarded,
feedback: validation.feedback,
totalScore: newScore
}
});
};
/**
* Complete a lesson
* POST /api/participant/lesson/:eventLessonId/complete
*/
const completeLesson = async (req, res) => {
const { eventLessonId } = req.params;
const participantId = req.participant.id;
// Get progress
const progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
if (!progress) {
throw new ApiError(404, 'Lesson progress not found. Start the lesson first.');
}
if (progress.status === 'completed') {
throw new ApiError(400, 'Lesson already completed');
}
// Mark as completed
const updated = await progressQueries.completeLesson(progress.id);
// Calculate lesson score details
const scoreDetails = await scoringService.calculateLessonScore(progress.id);
// Check if passed
const passed = await scoringService.checkLessonPassed(progress.id);
res.json({
success: true,
message: 'Lesson completed',
data: {
completedAt: updated.completed_at,
finalScore: updated.score,
maxPoints: scoreDetails.maxPoints,
percentage: scoreDetails.percentage,
passed
}
});
};
/**
* Get interactive component data
* GET /api/lessons/:lessonKey/interactive/:stepId
*/
const getInteractiveData = async (req, res) => {
const { lessonKey, stepId } = req.params;
const data = await lessonLoader.getInteractiveData(lessonKey, stepId);
res.json({
success: true,
data
});
};
/**
* Execute lesson-specific action (e.g., SQL query)
* POST /api/lesson/:eventLessonId/action/:action
*/
const executeLessonAction = async (req, res) => {
const { eventLessonId, action } = req.params;
const participantId = req.participant.id;
const actionData = req.body;
// Get progress to ensure lesson is started
const progress = await progressQueries.getLessonProgress(participantId, eventLessonId);
if (!progress) {
throw new ApiError(404, 'Lesson not started. Start the lesson first.');
}
// Get event lesson details to find lesson key
const eventLesson = await lessonQueries.getEventLessonById(eventLessonId);
const lessonKey = eventLesson.lesson_key;
// Load lesson module
const lessonModule = await lessonLoader.loadLesson(lessonKey);
// Execute action based on type
let result;
if (action === 'execute-query' && lessonModule.executeVulnerableQuery) {
// SQL Injection demo
const { searchTerm, mode } = actionData;
if (mode === 'safe' && lessonModule.executeSafeQuery) {
result = lessonModule.executeSafeQuery(searchTerm);
} else {
result = lessonModule.executeVulnerableQuery(searchTerm);
}
} else {
throw new ApiError(400, `Unsupported action: ${action}`);
}
res.json({
success: true,
data: result
});
};
module.exports = {
getEventLessons,
getLessonContent,
startLesson,
submitAnswer,
completeLesson,
getInteractiveData,
executeLessonAction
};
@@ -0,0 +1,129 @@
const { ApiError } = require('../middleware/errorHandler');
const { generateSessionToken } = require('../middleware/auth');
const participantQueries = require('../models/queries/participant.queries');
const eventQueries = require('../models/queries/event.queries');
/**
* Join an event with a pseudonym
* POST /api/participant/join
*/
const joinEvent = async (req, res) => {
const { pseudonym, eventId } = req.body;
// Validate input
if (!pseudonym || !eventId) {
throw new ApiError(400, 'Pseudonym and eventId are required');
}
// Validate pseudonym format
if (pseudonym.length < 3 || pseudonym.length > 50) {
throw new ApiError(400, 'Pseudonym must be between 3 and 50 characters');
}
// Check if event exists and is active
const event = await eventQueries.getEventById(eventId);
if (!event) {
throw new ApiError(404, 'Event not found');
}
if (!event.is_active) {
throw new ApiError(403, 'This event is no longer accepting participants');
}
// Check if pseudonym is already taken in this event
const exists = await participantQueries.pseudonymExists(pseudonym, eventId);
if (exists) {
throw new ApiError(409, 'Pseudonym already taken in this event. Please choose another.');
}
// Generate session token
const sessionToken = generateSessionToken();
// Create participant
const participant = await participantQueries.createParticipant(
pseudonym,
eventId,
sessionToken
);
res.status(201).json({
success: true,
message: 'Successfully joined event',
data: {
participant: {
id: participant.id,
pseudonym: participant.pseudonym,
eventId: participant.event_id
},
sessionToken,
event: {
id: event.id,
name: event.name,
description: event.description
}
}
});
};
/**
* Get list of active events
* GET /api/participant/events
*/
const getActiveEvents = async (req, res) => {
const events = await eventQueries.getActiveEvents();
res.json({
success: true,
data: events
});
};
/**
* Get participant's own progress
* GET /api/participant/progress
*/
const getProgress = async (req, res) => {
const participantId = req.participant.id;
const progress = await participantQueries.getParticipantProgress(participantId);
if (!progress) {
throw new ApiError(404, 'Participant not found');
}
res.json({
success: true,
data: progress
});
};
/**
* Get participant profile
* GET /api/participant/profile
*/
const getProfile = async (req, res) => {
const participant = await participantQueries.getParticipantById(req.participant.id);
if (!participant) {
throw new ApiError(404, 'Participant not found');
}
res.json({
success: true,
data: {
id: participant.id,
pseudonym: participant.pseudonym,
eventId: participant.event_id,
eventName: participant.event_name,
createdAt: participant.created_at,
lastActive: participant.last_active
}
});
};
module.exports = {
joinEvent,
getActiveEvents,
getProgress,
getProfile
};
+106
View File
@@ -0,0 +1,106 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const config = require('./config/environment');
const { pool } = require('./config/database');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
// Import routes
const participantRoutes = require('./routes/participant.routes');
const adminRoutes = require('./routes/admin.routes');
const lessonRoutes = require('./routes/lesson.routes');
// Initialize Express app
const app = express();
// Security middleware
app.use(helmet());
// CORS configuration
app.use(cors({
origin: config.corsOrigin,
credentials: true
}));
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Logging middleware
if (config.nodeEnv === 'development') {
app.use(morgan('dev'));
} else {
app.use(morgan('combined'));
}
// Health check endpoint
app.get('/health', async (req, res) => {
try {
// Check database connection
await pool.query('SELECT 1');
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
environment: config.nodeEnv,
database: 'connected'
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
environment: config.nodeEnv,
database: 'disconnected',
error: error.message
});
}
});
// API routes
app.get('/api', (req, res) => {
res.json({
message: 'Security Awareness Learning Platform API',
version: '1.0.0',
endpoints: {
participant: '/api/participant',
admin: '/api/admin',
lessons: '/api/lessons'
}
});
});
// Mount routes
app.use('/api/participant', participantRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/lesson', lessonRoutes);
// 404 handler
app.use(notFoundHandler);
// Error handling middleware
app.use(errorHandler);
// Start server
const PORT = config.port;
app.listen(PORT, () => {
console.log(`
========================================
Security Awareness Learning Platform
========================================
Environment: ${config.nodeEnv}
Server running on port: ${PORT}
Database: ${config.database.host}:${config.database.port}/${config.database.name}
========================================
`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM signal received: closing HTTP server');
pool.end(() => {
console.log('Database pool closed');
process.exit(0);
});
});
module.exports = app;
+168
View File
@@ -0,0 +1,168 @@
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const config = require('../config/environment');
const { ApiError } = require('./errorHandler');
const db = require('../config/database');
/**
* Generate JWT token for admin
*/
const generateAdminToken = (adminId, username) => {
return jwt.sign(
{ id: adminId, username, role: 'admin' },
config.jwtSecret,
{ expiresIn: '24h' }
);
};
/**
* Generate session token for participant
*/
const generateSessionToken = () => {
const { v4: uuidv4 } = require('uuid');
return uuidv4();
};
/**
* Hash password with bcrypt
*/
const hashPassword = async (password) => {
return await bcrypt.hash(password, 10);
};
/**
* Verify password with bcrypt
*/
const verifyPassword = async (password, hashedPassword) => {
return await bcrypt.compare(password, hashedPassword);
};
/**
* Middleware to verify admin JWT token
*/
const verifyAdminToken = async (req, res, next) => {
try {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new ApiError(401, 'No token provided');
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
// Verify token
const decoded = jwt.verify(token, config.jwtSecret);
if (decoded.role !== 'admin') {
throw new ApiError(403, 'Access denied. Admin privileges required.');
}
// Verify admin exists in database
const result = await db.query(
'SELECT id, username FROM admin_users WHERE id = $1',
[decoded.id]
);
if (result.rows.length === 0) {
throw new ApiError(401, 'Invalid token');
}
// Attach admin info to request
req.admin = {
id: decoded.id,
username: decoded.username
};
next();
} catch (error) {
if (error.name === 'JsonWebTokenError') {
next(new ApiError(401, 'Invalid token'));
} else if (error.name === 'TokenExpiredError') {
next(new ApiError(401, 'Token expired'));
} else {
next(error);
}
}
};
/**
* Middleware to verify participant session token
*/
const verifyParticipantToken = async (req, res, next) => {
try {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new ApiError(401, 'No session token provided');
}
const sessionToken = authHeader.substring(7);
// Verify token exists in database and get participant info
const result = await db.query(
`SELECT p.id, p.pseudonym, p.event_id, e.name as event_name, e.is_active
FROM participants p
JOIN events e ON e.id = p.event_id
WHERE p.session_token = $1`,
[sessionToken]
);
if (result.rows.length === 0) {
throw new ApiError(401, 'Invalid session token');
}
const participant = result.rows[0];
// Check if event is still active
if (!participant.is_active) {
throw new ApiError(403, 'Event is no longer active');
}
// Update last active timestamp
await db.query(
'UPDATE participants SET last_active = CURRENT_TIMESTAMP WHERE id = $1',
[participant.id]
);
// Attach participant info to request
req.participant = {
id: participant.id,
pseudonym: participant.pseudonym,
eventId: participant.event_id,
eventName: participant.event_name
};
next();
} catch (error) {
next(error);
}
};
/**
* Optional participant authentication (doesn't fail if no token)
*/
const optionalParticipantAuth = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
await verifyParticipantToken(req, res, next);
} else {
next();
}
} catch (error) {
next();
}
};
module.exports = {
generateAdminToken,
generateSessionToken,
hashPassword,
verifyPassword,
verifyAdminToken,
verifyParticipantToken,
optionalParticipantAuth
};
+73
View File
@@ -0,0 +1,73 @@
const config = require('../config/environment');
/**
* Custom error class for API errors
*/
class ApiError extends Error {
constructor(statusCode, message, errors = null) {
super(message);
this.statusCode = statusCode;
this.errors = errors;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
/**
* Error handling middleware
*/
const errorHandler = (err, req, res, next) => {
let { statusCode = 500, message, errors } = err;
// Log error
console.error('Error:', {
statusCode,
message,
path: req.path,
method: req.method,
...(config.nodeEnv === 'development' && { stack: err.stack })
});
// Don't leak error details in production
if (!err.isOperational && config.nodeEnv === 'production') {
message = 'Internal server error';
}
res.status(statusCode).json({
success: false,
error: {
message,
...(errors && { errors }),
...(config.nodeEnv === 'development' && { stack: err.stack })
}
});
};
/**
* Async error wrapper to catch errors in async route handlers
*/
const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
/**
* 404 handler
*/
const notFoundHandler = (req, res) => {
res.status(404).json({
success: false,
error: {
message: 'Route not found',
path: req.path
}
});
};
module.exports = {
ApiError,
errorHandler,
asyncHandler,
notFoundHandler
};
+167
View File
@@ -0,0 +1,167 @@
const db = require('../../config/database');
/**
* Create a new event
*/
const createEvent = async (name, description, startDate, endDate) => {
const query = `
INSERT INTO events (name, description, start_date, end_date, is_active)
VALUES ($1, $2, $3, $4, true)
RETURNING *
`;
const result = await db.query(query, [name, description, startDate, endDate]);
return result.rows[0];
};
/**
* Get event by ID
*/
const getEventById = async (eventId) => {
const query = `
SELECT
e.*,
COUNT(DISTINCT p.id) as participant_count,
COUNT(DISTINCT el.id) as lesson_count
FROM events e
LEFT JOIN participants p ON p.event_id = e.id
LEFT JOIN event_lessons el ON el.event_id = e.id
WHERE e.id = $1
GROUP BY e.id
`;
const result = await db.query(query, [eventId]);
return result.rows[0] || null;
};
/**
* Get all events
*/
const getAllEvents = async () => {
const query = `
SELECT
e.*,
COUNT(DISTINCT p.id) as participant_count,
COUNT(DISTINCT el.id) as lesson_count
FROM events e
LEFT JOIN participants p ON p.event_id = e.id
LEFT JOIN event_lessons el ON el.event_id = e.id
GROUP BY e.id
ORDER BY e.created_at DESC
`;
const result = await db.query(query);
return result.rows;
};
/**
* Get active events (for participant view)
*/
const getActiveEvents = async () => {
const query = `
SELECT
e.id,
e.name,
e.description,
e.start_date,
e.end_date,
COUNT(DISTINCT el.id) as lesson_count
FROM events e
LEFT JOIN event_lessons el ON el.event_id = e.id
WHERE e.is_active = true
GROUP BY e.id
ORDER BY e.start_date DESC NULLS LAST, e.created_at DESC
`;
const result = await db.query(query);
return result.rows;
};
/**
* Update event
*/
const updateEvent = async (eventId, updates) => {
const allowedFields = ['name', 'description', 'start_date', 'end_date', 'is_active'];
const fields = [];
const values = [];
let paramIndex = 1;
Object.keys(updates).forEach(key => {
if (allowedFields.includes(key) && updates[key] !== undefined) {
fields.push(`${key} = $${paramIndex}`);
values.push(updates[key]);
paramIndex++;
}
});
if (fields.length === 0) {
return null;
}
values.push(eventId);
const query = `
UPDATE events
SET ${fields.join(', ')}, updated_at = CURRENT_TIMESTAMP
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await db.query(query, values);
return result.rows[0] || null;
};
/**
* Delete event (cascades to participants and progress)
*/
const deleteEvent = async (eventId) => {
const query = 'DELETE FROM events WHERE id = $1 RETURNING id';
const result = await db.query(query, [eventId]);
return result.rows.length > 0;
};
/**
* Check if event exists
*/
const eventExists = async (eventId) => {
const query = 'SELECT id FROM events WHERE id = $1';
const result = await db.query(query, [eventId]);
return result.rows.length > 0;
};
/**
* Get event statistics
*/
const getEventStatistics = async (eventId) => {
const query = `
SELECT
e.id,
e.name,
COUNT(DISTINCT p.id) as total_participants,
COUNT(DISTINCT el.id) as total_lessons,
COUNT(DISTINCT lp.id) as total_lesson_starts,
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as total_completions,
ROUND(AVG(lp.score), 2) as average_score,
MAX(lp.score) as highest_score,
MIN(lp.score) as lowest_score
FROM events e
LEFT JOIN participants p ON p.event_id = e.id
LEFT JOIN event_lessons el ON el.event_id = e.id
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
WHERE e.id = $1
GROUP BY e.id, e.name
`;
const result = await db.query(query, [eventId]);
return result.rows[0] || null;
};
module.exports = {
createEvent,
getEventById,
getAllEvents,
getActiveEvents,
updateEvent,
deleteEvent,
eventExists,
getEventStatistics
};
@@ -0,0 +1,229 @@
const db = require('../../config/database');
/**
* Create a new lesson in the catalog
*/
const createLesson = async (lessonKey, title, description, modulePath, configPath, difficultyLevel, estimatedDuration) => {
const query = `
INSERT INTO lessons (lesson_key, title, description, module_path, config_path, difficulty_level, estimated_duration)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
const result = await db.query(query, [
lessonKey,
title,
description,
modulePath,
configPath,
difficultyLevel,
estimatedDuration
]);
return result.rows[0];
};
/**
* Get lesson by ID
*/
const getLessonById = async (lessonId) => {
const query = 'SELECT * FROM lessons WHERE id = $1';
const result = await db.query(query, [lessonId]);
return result.rows[0] || null;
};
/**
* Get lesson by key
*/
const getLessonByKey = async (lessonKey) => {
const query = 'SELECT * FROM lessons WHERE lesson_key = $1';
const result = await db.query(query, [lessonKey]);
return result.rows[0] || null;
};
/**
* Get all lessons
*/
const getAllLessons = async () => {
const query = `
SELECT * FROM lessons
ORDER BY title ASC
`;
const result = await db.query(query);
return result.rows;
};
/**
* Assign lesson to event
*/
const assignLessonToEvent = async (eventId, lessonId, orderIndex, maxPoints, weight, isRequired, unlockAfterLessonId) => {
const query = `
INSERT INTO event_lessons (event_id, lesson_id, order_index, max_points, weight, is_required, unlock_after_lesson_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`;
const result = await db.query(query, [
eventId,
lessonId,
orderIndex,
maxPoints || 100,
weight || 1.0,
isRequired !== undefined ? isRequired : true,
unlockAfterLessonId || null
]);
return result.rows[0];
};
/**
* Get lessons for an event
*/
const getEventLessons = async (eventId) => {
const query = `
SELECT
el.id as event_lesson_id,
el.order_index,
el.max_points,
el.weight,
el.is_required,
el.unlock_after_lesson_id,
l.*
FROM event_lessons el
JOIN lessons l ON l.id = el.lesson_id
WHERE el.event_id = $1
ORDER BY el.order_index ASC
`;
const result = await db.query(query, [eventId]);
return result.rows;
};
/**
* Get lessons for an event with participant progress
*/
const getEventLessonsWithProgress = async (eventId, participantId) => {
const query = `
SELECT
el.id as event_lesson_id,
el.order_index,
el.max_points,
el.weight,
el.is_required,
el.unlock_after_lesson_id,
l.*,
lp.id as progress_id,
lp.status,
lp.score,
lp.attempts,
lp.started_at,
lp.completed_at,
CASE
WHEN el.unlock_after_lesson_id IS NULL THEN true
WHEN EXISTS (
SELECT 1 FROM lesson_progress lp2
JOIN event_lessons el2 ON el2.id = lp2.event_lesson_id
WHERE lp2.participant_id = $2
AND el2.lesson_id = el.unlock_after_lesson_id
AND lp2.status = 'completed'
) THEN true
ELSE false
END as is_unlocked
FROM event_lessons el
JOIN lessons l ON l.id = el.lesson_id
LEFT JOIN lesson_progress lp ON lp.event_lesson_id = el.id AND lp.participant_id = $2
WHERE el.event_id = $1
ORDER BY el.order_index ASC
`;
const result = await db.query(query, [eventId, participantId]);
return result.rows;
};
/**
* Get event lesson by ID
*/
const getEventLessonById = async (eventLessonId) => {
const query = `
SELECT
el.*,
l.lesson_key,
l.title,
l.description,
l.module_path,
l.config_path,
l.difficulty_level,
l.estimated_duration
FROM event_lessons el
JOIN lessons l ON l.id = el.lesson_id
WHERE el.id = $1
`;
const result = await db.query(query, [eventLessonId]);
return result.rows[0] || null;
};
/**
* Update event lesson configuration
*/
const updateEventLesson = async (eventLessonId, updates) => {
const allowedFields = ['order_index', 'max_points', 'weight', 'is_required', 'unlock_after_lesson_id'];
const fields = [];
const values = [];
let paramIndex = 1;
Object.keys(updates).forEach(key => {
if (allowedFields.includes(key) && updates[key] !== undefined) {
fields.push(`${key} = $${paramIndex}`);
values.push(updates[key]);
paramIndex++;
}
});
if (fields.length === 0) {
return null;
}
values.push(eventLessonId);
const query = `
UPDATE event_lessons
SET ${fields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *
`;
const result = await db.query(query, values);
return result.rows[0] || null;
};
/**
* Remove lesson from event
*/
const removeEventLesson = async (eventLessonId) => {
const query = 'DELETE FROM event_lessons WHERE id = $1 RETURNING id';
const result = await db.query(query, [eventLessonId]);
return result.rows.length > 0;
};
/**
* Check if lesson exists
*/
const lessonExists = async (lessonId) => {
const query = 'SELECT id FROM lessons WHERE id = $1';
const result = await db.query(query, [lessonId]);
return result.rows.length > 0;
};
module.exports = {
createLesson,
getLessonById,
getLessonByKey,
getAllLessons,
assignLessonToEvent,
getEventLessons,
getEventLessonsWithProgress,
getEventLessonById,
updateEventLesson,
removeEventLesson,
lessonExists
};
@@ -0,0 +1,142 @@
const db = require('../../config/database');
/**
* Create a new participant
*/
const createParticipant = async (pseudonym, eventId, sessionToken) => {
const query = `
INSERT INTO participants (pseudonym, event_id, session_token)
VALUES ($1, $2, $3)
RETURNING id, pseudonym, event_id, session_token, created_at
`;
const result = await db.query(query, [pseudonym, eventId, sessionToken]);
return result.rows[0];
};
/**
* Get participant by ID
*/
const getParticipantById = async (participantId) => {
const query = `
SELECT p.*, e.name as event_name, e.is_active as event_active
FROM participants p
JOIN events e ON e.id = p.event_id
WHERE p.id = $1
`;
const result = await db.query(query, [participantId]);
return result.rows[0] || null;
};
/**
* Get participant by session token
*/
const getParticipantByToken = async (sessionToken) => {
const query = `
SELECT p.*, e.name as event_name, e.is_active as event_active
FROM participants p
JOIN events e ON e.id = p.event_id
WHERE p.session_token = $1
`;
const result = await db.query(query, [sessionToken]);
return result.rows[0] || null;
};
/**
* Check if pseudonym exists in event
*/
const pseudonymExists = async (pseudonym, eventId) => {
const query = `
SELECT id FROM participants
WHERE pseudonym = $1 AND event_id = $2
`;
const result = await db.query(query, [pseudonym, eventId]);
return result.rows.length > 0;
};
/**
* Get all participants for an event
*/
const getParticipantsByEvent = async (eventId) => {
const query = `
SELECT
p.id,
p.pseudonym,
p.created_at,
p.last_active,
COUNT(DISTINCT lp.id) as total_lessons_started,
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as lessons_completed,
COALESCE(SUM(lp.score), 0) as total_score
FROM participants p
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
WHERE p.event_id = $1
GROUP BY p.id, p.pseudonym, p.created_at, p.last_active
ORDER BY total_score DESC, p.pseudonym ASC
`;
const result = await db.query(query, [eventId]);
return result.rows;
};
/**
* Get participant progress summary
*/
const getParticipantProgress = async (participantId) => {
const query = `
SELECT
p.pseudonym,
p.event_id,
e.name as event_name,
COUNT(DISTINCT lp.id) as total_lessons_started,
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as lessons_completed,
COALESCE(SUM(lp.score), 0) as total_score,
COUNT(DISTINCT el.id) as total_lessons_available
FROM participants p
JOIN events e ON e.id = p.event_id
LEFT JOIN event_lessons el ON el.event_id = p.event_id
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
WHERE p.id = $1
GROUP BY p.id, p.pseudonym, p.event_id, e.name
`;
const result = await db.query(query, [participantId]);
return result.rows[0] || null;
};
/**
* Delete participant
*/
const deleteParticipant = async (participantId) => {
const query = 'DELETE FROM participants WHERE id = $1 RETURNING id';
const result = await db.query(query, [participantId]);
return result.rows.length > 0;
};
/**
* Update last active timestamp
*/
const updateLastActive = async (participantId) => {
const query = `
UPDATE participants
SET last_active = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING last_active
`;
const result = await db.query(query, [participantId]);
return result.rows[0];
};
module.exports = {
createParticipant,
getParticipantById,
getParticipantByToken,
pseudonymExists,
getParticipantsByEvent,
getParticipantProgress,
deleteParticipant,
updateLastActive
};
@@ -0,0 +1,206 @@
const db = require('../../config/database');
/**
* Start a lesson (create or update progress record)
*/
const startLesson = async (participantId, eventLessonId) => {
const query = `
INSERT INTO lesson_progress (participant_id, event_lesson_id, status, started_at, current_step)
VALUES ($1, $2, 'in_progress', CURRENT_TIMESTAMP, 0)
ON CONFLICT (participant_id, event_lesson_id)
DO UPDATE SET
status = 'in_progress',
started_at = COALESCE(lesson_progress.started_at, CURRENT_TIMESTAMP),
updated_at = CURRENT_TIMESTAMP
RETURNING *
`;
const result = await db.query(query, [participantId, eventLessonId]);
return result.rows[0];
};
/**
* Update current step in lesson
*/
const updateStep = async (progressId, stepIndex) => {
const query = `
UPDATE lesson_progress
SET current_step = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING *
`;
const result = await db.query(query, [stepIndex, progressId]);
return result.rows[0];
};
/**
* Complete a lesson
*/
const completeLesson = async (progressId) => {
const query = `
UPDATE lesson_progress
SET status = 'completed', completed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *
`;
const result = await db.query(query, [progressId]);
return result.rows[0];
};
/**
* Get progress for a specific lesson
*/
const getLessonProgress = async (participantId, eventLessonId) => {
const query = `
SELECT
lp.*,
el.max_points,
el.weight,
l.title as lesson_title,
l.lesson_key
FROM lesson_progress lp
JOIN event_lessons el ON el.id = lp.event_lesson_id
JOIN lessons l ON l.id = el.lesson_id
WHERE lp.participant_id = $1 AND lp.event_lesson_id = $2
`;
const result = await db.query(query, [participantId, eventLessonId]);
return result.rows[0] || null;
};
/**
* Get progress by ID
*/
const getProgressById = async (progressId) => {
const query = `
SELECT
lp.*,
el.max_points,
el.weight,
l.lesson_key
FROM lesson_progress lp
JOIN event_lessons el ON el.id = lp.event_lesson_id
JOIN lessons l ON l.id = el.lesson_id
WHERE lp.id = $1
`;
const result = await db.query(query, [progressId]);
return result.rows[0] || null;
};
/**
* Get all progress for a participant
*/
const getParticipantProgress = async (participantId) => {
const query = `
SELECT
lp.*,
el.max_points,
el.weight,
el.order_index,
l.lesson_key,
l.title,
l.description,
l.difficulty_level
FROM lesson_progress lp
JOIN event_lessons el ON el.id = lp.event_lesson_id
JOIN lessons l ON l.id = el.lesson_id
WHERE lp.participant_id = $1
ORDER BY el.order_index
`;
const result = await db.query(query, [participantId]);
return result.rows;
};
/**
* Save an answer
*/
const saveAnswer = async (progressId, questionKey, answerData, isCorrect, pointsAwarded, feedback) => {
const query = `
INSERT INTO lesson_answers (lesson_progress_id, question_key, answer_data, is_correct, points_awarded, feedback)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *
`;
const result = await db.query(query, [
progressId,
questionKey,
JSON.stringify(answerData),
isCorrect,
pointsAwarded,
feedback
]);
return result.rows[0];
};
/**
* Update score for lesson progress
*/
const updateScore = async (progressId, pointsToAdd) => {
const query = `
UPDATE lesson_progress
SET score = score + $1, attempts = attempts + 1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING score
`;
const result = await db.query(query, [pointsToAdd, progressId]);
return result.rows[0]?.score || 0;
};
/**
* Get answers for a lesson progress
*/
const getAnswers = async (progressId) => {
const query = `
SELECT * FROM lesson_answers
WHERE lesson_progress_id = $1
ORDER BY submitted_at ASC
`;
const result = await db.query(query, [progressId]);
return result.rows;
};
/**
* Check if lesson is unlocked for participant
*/
const isLessonUnlocked = async (participantId, eventLessonId) => {
const query = `
SELECT
el.unlock_after_lesson_id,
CASE
WHEN el.unlock_after_lesson_id IS NULL THEN true
WHEN EXISTS (
SELECT 1 FROM lesson_progress lp2
JOIN event_lessons el2 ON el2.id = lp2.event_lesson_id
WHERE lp2.participant_id = $1
AND el2.lesson_id = el.unlock_after_lesson_id
AND lp2.status = 'completed'
) THEN true
ELSE false
END as is_unlocked
FROM event_lessons el
WHERE el.id = $2
`;
const result = await db.query(query, [participantId, eventLessonId]);
return result.rows[0]?.is_unlocked || false;
};
module.exports = {
startLesson,
updateStep,
completeLesson,
getLessonProgress,
getProgressById,
getParticipantProgress,
saveAnswer,
updateScore,
getAnswers,
isLessonUnlocked
};
+32
View File
@@ -0,0 +1,32 @@
const express = require('express');
const router = express.Router();
const { asyncHandler } = require('../middleware/errorHandler');
const { verifyAdminToken } = require('../middleware/auth');
const adminController = require('../controllers/admin.controller');
const eventController = require('../controllers/event.controller');
const adminLessonController = require('../controllers/adminLesson.controller');
// Admin authentication routes
router.post('/login', asyncHandler(adminController.login));
router.get('/profile', verifyAdminToken, asyncHandler(adminController.getProfile));
router.get('/verify', verifyAdminToken, asyncHandler(adminController.verifyToken));
// Event management routes (admin only)
router.get('/events', verifyAdminToken, asyncHandler(eventController.getAllEvents));
router.post('/events', verifyAdminToken, asyncHandler(eventController.createEvent));
router.get('/events/:eventId', verifyAdminToken, asyncHandler(eventController.getEventById));
router.put('/events/:eventId', verifyAdminToken, asyncHandler(eventController.updateEvent));
router.delete('/events/:eventId', verifyAdminToken, asyncHandler(eventController.deleteEvent));
// Event participant management
router.get('/events/:eventId/participants', verifyAdminToken, asyncHandler(eventController.getEventParticipants));
router.get('/events/:eventId/analytics', verifyAdminToken, asyncHandler(eventController.getEventAnalytics));
// Lesson management routes (admin only)
router.get('/lessons', verifyAdminToken, asyncHandler(adminLessonController.getAllLessons));
router.post('/events/:eventId/lessons', verifyAdminToken, asyncHandler(adminLessonController.assignLessonToEvent));
router.get('/events/:eventId/lessons', verifyAdminToken, asyncHandler(adminLessonController.getEventLessons));
router.put('/events/:eventId/lessons/:eventLessonId', verifyAdminToken, asyncHandler(adminLessonController.updateEventLesson));
router.delete('/events/:eventId/lessons/:eventLessonId', verifyAdminToken, asyncHandler(adminLessonController.removeEventLesson));
module.exports = router;
+31
View File
@@ -0,0 +1,31 @@
const express = require('express');
const router = express.Router();
const { asyncHandler } = require('../middleware/errorHandler');
const { verifyParticipantToken } = require('../middleware/auth');
const lessonController = require('../controllers/lesson.controller');
// All lesson routes require participant authentication
router.use(verifyParticipantToken);
// Get lessons for an event
router.get('/event/:eventId/lessons', asyncHandler(lessonController.getEventLessons));
// Get lesson content
router.get('/:eventLessonId', asyncHandler(lessonController.getLessonContent));
// Start a lesson
router.post('/:eventLessonId/start', asyncHandler(lessonController.startLesson));
// Submit an answer
router.post('/:eventLessonId/answer', asyncHandler(lessonController.submitAnswer));
// Complete a lesson
router.post('/:eventLessonId/complete', asyncHandler(lessonController.completeLesson));
// Execute lesson-specific action
router.post('/:eventLessonId/action/:action', asyncHandler(lessonController.executeLessonAction));
// Get interactive component data
router.get('/:lessonKey/interactive/:stepId', asyncHandler(lessonController.getInteractiveData));
module.exports = router;
+15
View File
@@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { asyncHandler } = require('../middleware/errorHandler');
const { verifyParticipantToken } = require('../middleware/auth');
const participantController = require('../controllers/participant.controller');
// Public routes (no authentication required)
router.post('/join', asyncHandler(participantController.joinEvent));
router.get('/events', asyncHandler(participantController.getActiveEvents));
// Protected routes (require participant session token)
router.get('/profile', verifyParticipantToken, asyncHandler(participantController.getProfile));
router.get('/progress', verifyParticipantToken, asyncHandler(participantController.getProgress));
module.exports = router;
@@ -0,0 +1,131 @@
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const config = require('../config/environment');
// Cache for loaded lessons
const lessonCache = new Map();
/**
* Load a lesson module and its configuration
*/
const loadLesson = async (lessonKey) => {
// Check cache first
if (lessonCache.has(lessonKey)) {
return lessonCache.get(lessonKey);
}
try {
// Load YAML configuration
const configPath = path.join(
process.cwd(),
config.lessonsPath,
'configs',
`${lessonKey}.yaml`
);
if (!fs.existsSync(configPath)) {
throw new Error(`Lesson configuration not found: ${lessonKey}.yaml`);
}
const configContent = fs.readFileSync(configPath, 'utf8');
const lessonConfig = yaml.load(configContent);
// Validate config has required fields
if (!lessonConfig.lessonKey || !lessonConfig.title || !lessonConfig.module) {
throw new Error(`Invalid lesson configuration for ${lessonKey}`);
}
// Load JavaScript module
const modulePath = path.join(
process.cwd(),
config.lessonsPath,
'modules',
lessonConfig.module,
'index.js'
);
if (!fs.existsSync(modulePath)) {
throw new Error(`Lesson module not found: ${lessonConfig.module}/index.js`);
}
// Require the module
const LessonClass = require(modulePath);
// Instantiate the lesson module with config
const lessonInstance = new LessonClass(lessonConfig);
// Cache the instance
lessonCache.set(lessonKey, lessonInstance);
return lessonInstance;
} catch (error) {
console.error(`Error loading lesson ${lessonKey}:`, error);
throw new Error(`Failed to load lesson: ${error.message}`);
}
};
/**
* Get lesson content (for rendering to participant)
*/
const getLessonContent = async (lessonKey) => {
const lesson = await loadLesson(lessonKey);
return lesson.getContent();
};
/**
* Validate an answer using the lesson module
*/
const validateAnswer = async (lessonKey, questionId, answer) => {
const lesson = await loadLesson(lessonKey);
return await lesson.validateAnswer(questionId, answer);
};
/**
* Get interactive component data
*/
const getInteractiveData = async (lessonKey, stepId) => {
const lesson = await loadLesson(lessonKey);
return await lesson.getInteractiveData(stepId);
};
/**
* Clear lesson cache (useful for development/testing)
*/
const clearCache = () => {
lessonCache.clear();
};
/**
* Reload a specific lesson from disk
*/
const reloadLesson = async (lessonKey) => {
lessonCache.delete(lessonKey);
return await loadLesson(lessonKey);
};
/**
* List all available lesson configurations
*/
const listAvailableLessons = () => {
const configsPath = path.join(process.cwd(), config.lessonsPath, 'configs');
if (!fs.existsSync(configsPath)) {
return [];
}
const files = fs.readdirSync(configsPath);
return files
.filter(file => file.endsWith('.yaml') || file.endsWith('.yml'))
.map(file => file.replace(/\.(yaml|yml)$/, ''));
};
module.exports = {
loadLesson,
getLessonContent,
validateAnswer,
getInteractiveData,
clearCache,
reloadLesson,
listAvailableLessons
};
+155
View File
@@ -0,0 +1,155 @@
const db = require('../config/database');
/**
* Calculate total score for a participant in an event
*/
const calculateTotalScore = async (participantId) => {
const query = `
SELECT
SUM(lp.score) as total_score,
SUM(el.max_points * el.weight) as max_possible_score,
COUNT(DISTINCT el.id) as total_lessons,
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN el.id END) as completed_lessons
FROM participants p
JOIN event_lessons el ON el.event_id = p.event_id
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id AND lp.event_lesson_id = el.id
WHERE p.id = $1
GROUP BY p.id
`;
const result = await db.query(query, [participantId]);
if (result.rows.length === 0) {
return {
totalScore: 0,
maxPossibleScore: 0,
percentage: 0,
completedLessons: 0,
totalLessons: 0
};
}
const data = result.rows[0];
return {
totalScore: parseInt(data.total_score) || 0,
maxPossibleScore: parseFloat(data.max_possible_score) || 0,
percentage: data.max_possible_score > 0
? ((data.total_score / data.max_possible_score) * 100).toFixed(2)
: 0,
completedLessons: parseInt(data.completed_lessons) || 0,
totalLessons: parseInt(data.total_lessons) || 0
};
};
/**
* Calculate weighted score for a specific lesson
*/
const calculateLessonScore = async (progressId) => {
const query = `
SELECT
lp.score,
el.max_points,
el.weight
FROM lesson_progress lp
JOIN event_lessons el ON el.id = lp.event_lesson_id
WHERE lp.id = $1
`;
const result = await db.query(query, [progressId]);
if (result.rows.length === 0) {
return { score: 0, weightedScore: 0, percentage: 0 };
}
const data = result.rows[0];
const percentage = (data.score / data.max_points) * 100;
const weightedScore = (data.score / data.max_points) * data.max_points * data.weight;
return {
score: data.score,
maxPoints: data.max_points,
weight: data.weight,
percentage: percentage.toFixed(2),
weightedScore: weightedScore.toFixed(2)
};
};
/**
* Get leaderboard for an event
*/
const getEventLeaderboard = async (eventId, limit = 10) => {
const query = `
SELECT
p.pseudonym,
SUM(lp.score) as total_score,
COUNT(DISTINCT CASE WHEN lp.status = 'completed' THEN lp.id END) as completed_lessons,
MAX(lp.updated_at) as last_activity
FROM participants p
LEFT JOIN lesson_progress lp ON lp.participant_id = p.id
WHERE p.event_id = $1
GROUP BY p.id, p.pseudonym
ORDER BY total_score DESC, completed_lessons DESC, last_activity DESC
LIMIT $2
`;
const result = await db.query(query, [eventId, limit]);
return result.rows.map((row, index) => ({
rank: index + 1,
pseudonym: row.pseudonym,
totalScore: parseInt(row.total_score) || 0,
completedLessons: parseInt(row.completed_lessons) || 0,
lastActivity: row.last_activity
}));
};
/**
* Award points for a correct answer
*/
const awardPoints = async (progressId, points) => {
const query = `
UPDATE lesson_progress
SET score = score + $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING score
`;
const result = await db.query(query, [points, progressId]);
return result.rows[0]?.score || 0;
};
/**
* Check if participant passed the lesson
*/
const checkLessonPassed = async (progressId) => {
const query = `
SELECT
lp.score,
el.max_points,
l.lesson_key
FROM lesson_progress lp
JOIN event_lessons el ON el.id = lp.event_lesson_id
JOIN lessons l ON l.id = el.lesson_id
WHERE lp.id = $1
`;
const result = await db.query(query, [progressId]);
if (result.rows.length === 0) {
return false;
}
const data = result.rows[0];
const percentage = (data.score / data.max_points) * 100;
// Default passing threshold is 70%
return percentage >= 70;
};
module.exports = {
calculateTotalScore,
calculateLessonScore,
getEventLeaderboard,
awardPoints,
checkLessonPassed
};
+61
View File
@@ -0,0 +1,61 @@
const db = require('../config/database');
const lessonQueries = require('../models/queries/lesson.queries');
/**
* Seed lessons into the database
*/
const seedLessons = async () => {
const lessons = [
{
lessonKey: 'phishing-email-basics',
title: 'Phishing Email Detection Basics',
description: 'Learn to identify common phishing tactics in emails and protect yourself from email-based attacks',
modulePath: 'phishing-email-basics',
configPath: 'phishing-email-basics.yaml',
difficultyLevel: 'beginner',
estimatedDuration: 15
}
];
for (const lesson of lessons) {
try {
// Check if lesson already exists
const existing = await lessonQueries.getLessonByKey(lesson.lessonKey);
if (existing) {
console.log(`Lesson "${lesson.lessonKey}" already exists, skipping...`);
continue;
}
// Create lesson
await lessonQueries.createLesson(
lesson.lessonKey,
lesson.title,
lesson.description,
lesson.modulePath,
lesson.configPath,
lesson.difficultyLevel,
lesson.estimatedDuration
);
console.log(`✓ Created lesson: ${lesson.title}`);
} catch (error) {
console.error(`✗ Error creating lesson "${lesson.lessonKey}":`, error.message);
}
}
};
// Run if called directly
if (require.main === module) {
seedLessons()
.then(() => {
console.log('\n✓ Lesson seeding complete');
process.exit(0);
})
.catch(error => {
console.error('\n✗ Lesson seeding failed:', error);
process.exit(1);
});
}
module.exports = { seedLessons };