Merge pull request #36 from linux-odyssey/security

Security
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-11-02 13:24:57 +08:00
commit 3da809e107
55 files changed
+744 -351

No files matched your search

+1
View File
@@ -27,5 +27,6 @@ module.exports = {
'no-var': 'error',
'no-plusplus': 'off',
'prettier/prettier': ['error', { endOfLine: 'auto', semi: false }],
'import/prefer-default-export': 'off',
},
}
+6 -2
View File
@@ -32,10 +32,14 @@ Run Cypress locally, using the current development containers:
Run Cypress in container, create brand-new containers along with it:
docker compose -f docker-compose.testing.yml run cypress
./docker-scripts.sh test
# or with building
./docker-scripts.sh test --build
# tearing off the containers
docker compose -f docker-compose.testing.yml down
./docker-scripts.sh down
### Deployment
+2
View File
@@ -5,6 +5,7 @@ COPY package.json yarn.lock ./
COPY app/package.json ./app/package.json
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY packages/constants/package.json ./packages/constants/package.json
RUN yarn install --frozen-lockfile
# Dev stage
@@ -16,6 +17,7 @@ CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
FROM base as build
COPY packages/file-graph ./packages/file-graph
COPY packages/utils ./packages/utils
COPY packages/constants ./packages/constants
COPY app ./app
RUN yarn build
-6
View File
@@ -5,12 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue</title>
<script
async
defer
data-domain="linuxodyssey.xyz"
src="https://analytics.indie.tw/js/script.js"
></script>
</head>
<body>
<div id="app"></div>
+4 -2
View File
@@ -11,15 +11,17 @@
"preview": "vite preview"
},
"dependencies": {
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@linux-odyssey/constants": "*",
"axios": "^1.4.0",
"socket.io-client": "^4.7.1",
"validator": "^13.11.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"xterm": "^5.2.1",
+3
View File
@@ -128,6 +128,7 @@ const handleChange = () => {
class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary"
:placeholder="type === 'login' ? 'Email / Username' : 'Username'"
v-model="username"
required
@input="handleChange()"
autocomplete="username"
/>
@@ -147,6 +148,7 @@ const handleChange = () => {
class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary"
placeholder="Email"
v-model="email"
required
@input="handleChange()"
autocomplete="email"
/>
@@ -158,6 +160,7 @@ const handleChange = () => {
class="my-4 bg-background-primary text-text-primary bg- rounded-md block w-full px-3 h-10 shadow-sm focus:outline-none placeholder:text-text-line focus:ring-2 focus:ring-text-primary ring-1 ring-background-secondary"
placeholder="Password"
v-model="password"
required
@input="handleChange()"
/>
</div>
-4
View File
@@ -30,13 +30,9 @@
<script setup>
import sessionManager from '../utils/session'
import socket from '../utils/socket'
import socketTerminal from '../utils/terminal'
const reset = async () => {
console.log('resetting...')
await sessionManager.createSession()
socket.connect(sessionManager.getSession())
socketTerminal.clear()
}
</script>
+3 -3
View File
@@ -10,12 +10,12 @@
<script setup>
import { onMounted, ref } from 'vue'
import socketTerminal from '../utils/terminal'
import sessionManager from '../utils/session'
const terminal = ref(null)
onMounted(() => {
socketTerminal.mount(terminal.value)
window.addEventListener('resize', () => socketTerminal.resizeScreen())
sessionManager.term.mount(terminal.value)
window.addEventListener('resize', () => sessionManager.term.resizeScreen())
})
</script>
+67 -15
View File
@@ -1,31 +1,83 @@
import api from './api'
import {
TooManyRequestsError,
UnauthorizedError,
ValidationError,
} from './errors'
export async function isLoggedIn() {
const res = await api.get('/auth/check-session')
const { loggedIn } = res.data
return loggedIn
}
export const login = async (username, password) => {
try {
await api.post('/auth/login', { username, password })
return true
} catch (err) {
if (err.response?.status === 401) {
return false
}
throw err
function handleRequestError(err) {
if (!err.response) throw err // Not an axios error
const { status } = err.response
if (status === 429) {
throw new TooManyRequestsError(err.response.data.message)
}
if (status === 401) {
throw new UnauthorizedError(err.response.data.message)
}
if (status === 400) {
const { errors } = err.response.data
if (Array.isArray(errors) && errors.length > 0) {
const { path, msg } = errors[0]
switch (path) {
case 'username':
throw new ValidationError('Username is already taken')
case 'email':
throw new ValidationError('Email is already taken')
case 'password':
throw new ValidationError('Password is too weak')
default:
throw new ValidationError(msg)
}
}
throw new ValidationError(err.response.data.message)
}
throw err
}
export const login = (username, password) => {
return api
.post('/auth/login', { username, password })
.then(() => true)
.catch(handleRequestError)
}
export const register = (username, email, password) => {
return api.post('/auth/register', {
username,
password,
email,
})
return api
.post('/auth/register', {
username,
password,
email,
})
.then(() => true)
.catch(handleRequestError)
}
export const checkUsername = async (username) => {
return api
.get('/auth/check-username', {
params: { username },
})
.then(() => true)
.catch(handleRequestError)
}
export const chooseUsername = async (username) => {
return api
.post('/auth/register-from-session', { username })
.then(() => true)
.catch(handleRequestError)
}
export const logout = () => {
return api.post('/auth/logout')
return api
.post('/auth/logout')
.then(() => true)
.catch(handleRequestError)
}
+27
View File
@@ -0,0 +1,27 @@
/* eslint-disable max-classes-per-file */
export class TooManyRequestsError extends Error {
constructor(message) {
super(message)
this.name = 'TooManyRequestsError'
}
}
export class ValidationError extends Error {
constructor(message) {
super(message)
this.name = 'ValidationError'
}
}
export class UnauthorizedError extends Error {
constructor(message) {
super(message)
this.name = 'UnauthorizedError'
}
}
export default {
TooManyRequestsError,
ValidationError,
UnauthorizedError,
}
+40 -26
View File
@@ -1,6 +1,8 @@
import { ref } from 'vue'
import { FileGraph } from '@linux-odyssey/file-graph'
import api from './api'
import Socket from './socket'
import SocketTerminal from './terminal'
class SessionManager {
constructor(questId) {
@@ -16,17 +18,44 @@ class SessionManager {
)
this.pwd = ref('')
this.status = ref('inactive')
this.socket = new Socket()
this.term = new SocketTerminal(40, 80)
}
init() {
this.socket.on('terminal', (data) => {
this.term.write(data)
})
this.term.onData((data) => {
this.socket.emit('terminal', data)
})
this.socket.on('graph', (event) => {
this.handleGraphUpdate(event)
})
this.socket.on('hints', (event) => {
this.handleHintUpdate(event)
})
this.socket.on('tasks', (tasks) => {
this.setTasks(tasks)
})
this.socket.on('status', (event) => {
this.handleStatusUpdate(event)
})
return this.getActiveSession()
}
getSession() {
return this.session.value
}
setSession(session) {
async setSession(session) {
this.session.value = session
this.graph.value = new FileGraph(session.graph)
this.hints.value = session.hints
this.status.value = session.status
this.term.reset()
await this.socket.connect(session)
this.term.focus()
}
handleGraphUpdate(event) {
@@ -58,33 +87,18 @@ class SessionManager {
this.setSession(data)
}
async getSessionList() {
const res = await api.get('/sessions', {
params: { quest_id: this.questId },
async getActiveSession() {
const res = await api.post('/sessions/active', {
quest_id: this.questId,
})
const { data } = res
return data
}
async lastSession() {
const sessions = await this.getSessionList()
if (sessions.length === 0) {
return
const session = res.data
console.log(session)
try {
await this.setSession(session)
} catch (err) {
console.log(err)
this.createSession().catch(console.error)
}
const { _id: sessionId } = sessions[sessions.length - 1]
const { data: session } = await api.get(`/sessions/${sessionId}`)
console.log(
`Last Session ID: ${session._id}, Quest: ${session.quest}, Created At: ${session.createdAt}`
)
this.setSession(session)
}
async lastOrCreate() {
await this.lastSession()
if (!this.session.value) {
await this.createSession()
}
console.log(this.session.value)
}
}
+27 -15
View File
@@ -7,22 +7,28 @@ class SocketWrapper {
}
connect(session) {
if (this.socket) {
this.socket.off()
this.socket.disconnect()
}
return new Promise((resolve, reject) => {
if (this.socket) {
this.socket.off()
this.socket.disconnect()
}
this.socket = io('', {
query: {
session_id: session._id,
},
this.socket = io('', {
query: {
session_id: session._id,
},
})
this.socket.on('connect', () => {
console.log('Socket connected to session', session._id)
})
this.socket.once('terminal', () => {
resolve()
})
this.socket.once('error', reject)
this.bindListeners()
})
this.socket.on('connect', () => {
console.log('Socket connected to session', session._id)
})
this.bindListeners()
}
on(event, callback) {
@@ -32,6 +38,12 @@ class SocketWrapper {
}
}
once(event, callback) {
if (this.socket) {
this.socket.once(event, callback)
}
}
bindListeners() {
this.listeners.forEach(({ event, callback }) => {
this.socket.on(event, callback)
@@ -47,4 +59,4 @@ class SocketWrapper {
}
}
export default new SocketWrapper()
export default SocketWrapper
+15 -11
View File
@@ -1,6 +1,5 @@
import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit'
import socket from './socket'
import 'xterm/css/xterm.css'
class SocketTerminal {
@@ -24,13 +23,18 @@ class SocketTerminal {
this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon)
this.fitAddon.fit()
this.socket = socket
this.socket.on('terminal', (message) => {
this.term.write(message)
})
this.term.onData((key) => {
this.socket.emit('terminal', key)
})
}
write(message) {
this.term.write(message)
}
onData(callback) {
this.term.onData(callback)
}
focus() {
this.term.focus()
}
resizeScreen() {
@@ -43,9 +47,9 @@ class SocketTerminal {
this.resizeScreen()
}
clear() {
this.term.clear()
reset() {
this.term.reset()
}
}
export default new SocketTerminal(40, 100)
export default SocketTerminal
+35 -33
View File
@@ -2,44 +2,46 @@
import { isValidUsername } from '@linux-odyssey/utils'
import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors'
import { checkUsername, chooseUsername } from '../utils/auth'
import api from '../utils/api'
async function checkUsername({ username, error }) {
if (username && !isValidUsername(username)) {
error('Invalid username.')
return
}
if (username.length > 3) {
try {
const { data } = await api.get('/auth/check-username', {
params: { username },
})
const { available } = data
if (!available) {
error('Username is already taken.')
}
} catch {
async function check({ username, error }) {
if (username) {
if (!isValidUsername(username)) {
error('Invalid username.')
return
}
try {
await checkUsername(username)
} catch (err) {
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
return
}
if (err instanceof ValidationError) {
error(err.message)
return
}
error('Something went wrong. Please try again later.')
}
}
}
function handleSubmit({ username, success, error }) {
api
.post('/auth/register-from-session', {
username,
})
.then(success)
.catch((err) => {
if (err.response?.status === 409) {
error('Username is already taken.')
} else {
console.error(err.message)
error('Something went wrong.')
}
})
async function handleSubmit({ username, success, error }) {
try {
await chooseUsername(username)
success()
} catch (err) {
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
return
}
if (err instanceof ValidationError) {
error(err.message)
return
}
error(err.message)
}
}
</script>
<template>
@@ -51,7 +53,7 @@ function handleSubmit({ username, success, error }) {
<div class="h-2/3 w-1/3">
<AuthForm
@onSubmit="handleSubmit"
@onChange="checkUsername"
@onChange="check"
type="username"
:socialLogin="false"
/>
+1 -22
View File
@@ -1,6 +1,5 @@
<script setup>
import { computed, onMounted } from 'vue'
import socket from '../utils/socket'
import sessionManager from '../utils/session'
import GameHeaderPart from '../components/GameHeaderPart.vue'
import CommandlistPart from '../components/CommandlistPart.vue'
@@ -16,27 +15,7 @@ const completed = computed(() => {
})
onMounted(async () => {
await sessionManager.lastOrCreate()
socket.connect(sessionManager.getSession())
socket.on('connect', function open() {
console.log('Connected to the server.')
})
socket.on('disconnect', function close() {
console.log('Disconnected from the server.')
})
socket.on('graph', (event) => {
sessionManager.handleGraphUpdate(event)
})
socket.on('hints', (event) => {
sessionManager.handleHintUpdate(event)
})
socket.on('tasks', (tasks) => {
sessionManager.setTasks(tasks)
})
socket.on('message', console.log)
socket.on('status', (event) => {
sessionManager.handleStatusUpdate(event)
})
await sessionManager.init()
})
</script>
+18 -1
View File
@@ -2,15 +2,32 @@
import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue'
import {
TooManyRequestsError,
UnauthorizedError,
ValidationError,
} from '../utils/errors'
import { login } from '../utils/auth'
const handleLogin = async ({ username, password, success, error }) => {
try {
const isSuccess = await login(username, password)
if (isSuccess) success()
else error('Wrong username or password.')
} catch (err) {
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
return
}
if (err instanceof UnauthorizedError) {
error('Wrong username or password.')
return
}
if (err instanceof ValidationError) {
error('Invalid username or password.')
return
}
console.error(err)
error('Something went wrong.')
}
}
</script>
+35 -28
View File
@@ -1,55 +1,62 @@
<script setup>
import { isValidEmail, isValidUsername } from '@linux-odyssey/utils'
import validator from 'validator'
import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue'
import api from '../utils/api'
import { register } from '../utils/auth'
import { checkUsername, register } from '../utils/auth'
import {
UnauthorizedError,
TooManyRequestsError,
ValidationError,
} from '../utils/errors'
function handleRegister({ username, email, password, success, error }) {
register(username, email, password)
.then(success)
.catch((err) => {
if (err.response?.status === 409) {
// Handle username or email already exists error here
if (err.response.data.type === 'username') {
error('Username already exists.')
} else if (err.response.data.type === 'email') {
error('Email already exists.')
} else {
error('Something went wrong.')
}
} else if (err.response?.status === 400) {
error('Invalid username or email.')
} else {
console.error(err)
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
return
}
if (err instanceof UnauthorizedError) {
error('Invalid username or password.')
return
}
if (err instanceof ValidationError) {
error(err.message)
return
}
console.error(err)
error('Something went wrong.')
})
}
async function check({ username, email, error }) {
async function check({ username, email, password, error }) {
if (username && !isValidUsername(username)) {
error('Invalid username.')
return
}
if (email && !isValidEmail(email)) {
if (email && !validator.isEmail(email)) {
error('Invalid email.')
return
}
if (!username && !email) {
if (password && !validator.isStrongPassword(password, passwordPolicy)) {
error(
'Your password must be 8+ characters with at least one number, one upper and one lower case letter.'
)
return
}
try {
const res = await api.get('/auth/check-username', {
params: { username },
})
const { type, available } = res.data
if (!available) {
error(`${type} already exists.`)
}
await checkUsername(username)
} catch (err) {
if (err.response?.status === 400) {
error('Invalid username or email.')
if (err instanceof ValidationError) {
error(err.message)
return
}
if (err instanceof TooManyRequestsError) {
error('Too many requests. Try again in 2 minutes.')
return
}
console.error(err)
+2
View File
@@ -9,11 +9,13 @@ export default defineConfig({
'/api': {
target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false,
xfwd: true,
},
'/socket.io': {
target: process.env.API_TARGET || 'ws://localhost:3000',
changeOrigin: false,
ws: true,
xfwd: true,
},
},
},
+2
View File
@@ -9,6 +9,7 @@ server {
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api-docs/ {
@@ -18,6 +19,7 @@ server {
location /socket.io/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
+13 -6
View File
@@ -2,6 +2,8 @@ version: '3'
services:
app:
image: lancatlin/linux-odyssey:app
depends_on:
- backend
build:
context: .
dockerfile: app/Dockerfile
@@ -12,6 +14,8 @@ services:
- 8080:80
backend:
image: lancatlin/linux-odyssey:backend
depends_on:
- db
restart: on-failure
build:
context: .
@@ -25,8 +29,10 @@ services:
env_file:
- .env
environment:
- MONGO_URL=mongodb://user:user-password@db:27017/odyssey-test
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
- DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=${SECRET_KEY}
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
analytics:
image: lancatlin/linux-odyssey:analytics
@@ -41,7 +47,7 @@ services:
ports:
- '3001:3001'
environment:
- MONGO_URL=mongodb://user:user-password@db:27017/odyssey-test
- MONGO_URL=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@db:27017/${MONGO_DB}
db:
image: mongo
@@ -56,10 +62,10 @@ services:
environment:
- BASE_URL=${BASE_URL}
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=admin-password
- MONGO_USER=user
- MONGO_PASS=user-password
- MONGO_DB=odyssey-test
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
- MONGO_USER=${MONGO_USER}
- MONGO_PASS=${MONGO_PASSWORD}
- MONGO_DB=${MONGO_DB}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
@@ -81,6 +87,7 @@ networks:
internal:
players:
name: linux-odyssey-players
internal: true
volumes:
mongo:
+2
View File
@@ -42,6 +42,7 @@ services:
- BASE_URL=http://localhost:5173
- MONGO_URL=mongodb://user:user-password@db-test:27017/odyssey-test
- DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
@@ -76,3 +77,4 @@ networks:
internal:
players:
name: linux-odyssey-players
internal: true
+3
View File
@@ -36,6 +36,8 @@ services:
- BASE_URL=http://localhost:5173
- MONGO_URL=mongodb://db:27017/odyssey-test
- DOCKER_NETWORK=linux-odyssey-players
- SECRET_KEY=b1d2e917-17b9-4fce-9055-082dee6ec5d6 # Only for testing
- TRUSTED_PROXIES=${TRUSTED_PROXIES}
- HOST_PWD=${PWD}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
@@ -93,3 +95,4 @@ networks:
internal:
players:
name: linux-odyssey-players
internal: true
+11
View File
@@ -18,4 +18,15 @@ clean() {
docker rm -f $containers
}
test() {
if [ "$1" = "--build" ]; then
docker compose -f docker-compose.testing.yml build
fi
docker compose -f docker-compose.testing.yml run --rm cypress
}
down() {
docker compose -f docker-compose.testing.yml down
}
$@
+1
View File
@@ -22,6 +22,7 @@
"test": "yarn node --experimental-vm-modules $(yarn bin jest)",
"test:e2e": "yarn workspace tests test",
"analytics": "yarn workspace analytics dev",
"setup": "yarn workspace server setup",
"clean": "yarn workspace server clean"
},
"devDependencies": {
+11
View File
@@ -0,0 +1,11 @@
export const passwordPolicy = {
minLength: 8,
minNumbers: 1,
minLowercase: 1,
minUppercase: 1,
minSymbols: 0,
}
export default {
passwordPolicy,
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@linux-odyssey/constants",
"type": "module",
"version": "1.0.0",
"private": true,
"main": "index.js",
"exports": "./index.js",
"license": "GPL"
}
+5 -5
View File
@@ -1,15 +1,15 @@
export function isValidUsername(username) {
// check if the username is between 1 and 32 characters
if (username.length > 32) {
return false
}
// Define a regular expression pattern for a valid Linux username.
const usernamePattern = /^[a-z][a-z0-9_-]*$/
// Test the username against the pattern.
if (!usernamePattern.test(username)) return false
// check if the username is between 1 and 32 characters
if (username.length < 4 || username.length > 32) {
return false
}
const reservedWords = [
'root',
'admin',
+3 -1
View File
@@ -2,10 +2,11 @@ FROM node:20-alpine AS build
WORKDIR /app
COPY ./package.json yarn.lock ./
COPY server/package.json ./server/package.json
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY server/package.json ./server/package.json
RUN yarn install --frozen-lockfile --production
# Install docker cli
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
@@ -20,6 +21,7 @@ FROM build as prod
COPY packages/file-graph ./packages/file-graph
COPY packages/utils ./packages/utils
COPY packages/models ./packages/models
COPY packages/constants ./packages/constants
COPY ./server ./server
COPY quests ./quests
+6 -1
View File
@@ -7,18 +7,22 @@
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"test": "yarn node --experimental-vm-modules $(yarn bin jest)",
"setup": "node src/cli/setup.js",
"clean": "node src/cli/clean.js"
},
"dependencies": {
"@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/utils": "*",
"@linux-odyssey/models": "*",
"@linux-odyssey/utils": "*",
"bcrypt": "^5.1.1",
"commander": "^11.0.0",
"connect-mongo": "^5.0.0",
"dockerode": "^3.3.5",
"express": "^4.18.2",
"express-rate-limit": "^7.1.3",
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"jsonwebtoken": "^9.0.1",
"minimist": "^1.2.8",
"mongoose": "^7.4.0",
@@ -30,6 +34,7 @@
"passport-local": "^1.0.0",
"socket.io": "^4.7.1",
"swagger-ui-express": "^5.0.0",
"validator": "^13.11.0",
"yaml": "^2.3.1"
},
"devDependencies": {
+4 -72
View File
@@ -1,4 +1,4 @@
import { isValidEmail, isValidUsername } from '@linux-odyssey/utils'
import { matchedData } from 'express-validator'
import { User } from '@linux-odyssey/models'
import { genJWT, hashPassword } from '../../utils/auth.js'
@@ -12,67 +12,8 @@ export async function issueToken(req, res) {
res.json({ token })
}
async function invalidUsername(username) {
return !isValidUsername(username) || User.exists({ username })
}
async function invalidEmail(email) {
return !isValidEmail(email) || User.exists({ email })
}
export async function checkUsername(req, res) {
const { username, email } = req.query
const cred = username || email
if (!cred) {
res.status(400).json({
message: 'username or email is required',
})
return
}
if (isValidEmail(cred)) {
res.json({
type: 'email',
available: !(await User.exists({ email: cred })),
})
return
}
if (isValidUsername(cred)) {
res.json({
type: 'username',
available: !(await User.exists({ username: cred })),
})
return
}
res.status(400).json({
message: 'invalid username or email',
})
}
export async function register(req, res, next) {
const { username, password, email } = req.body
if (!(username && password && email)) {
res.status(400).json({
message: 'username, password, and email are required',
})
return
}
if (await invalidUsername(username)) {
res.status(409).json({
type: 'username',
message: 'Invalid username',
})
return
}
if (await invalidEmail(email)) {
res.status(409).json({
type: 'email',
message: 'Invalid email',
})
return
}
const { username, password, email } = matchedData(req)
const user = new User({
username,
@@ -81,7 +22,6 @@ export async function register(req, res, next) {
try {
user.hashedPassword = await hashPassword(password)
await user.save()
req.user = user
req.login(user, (err) => {
if (err) {
next(err)
@@ -131,24 +71,16 @@ export async function socialLogin(req, res) {
}
export async function registerFromSession(req, res) {
const { username } = req.body
const { username } = matchedData(req)
const { newUser } = req.session
if (!newUser) {
res.status(400).json({
res.status(401).json({
message: 'no new user found',
})
return
}
if (await invalidUsername(username)) {
res.status(409).json({
type: 'username',
message: 'Invalid username',
})
return
}
newUser.username = username
const user = new User(newUser)
+21 -46
View File
@@ -1,6 +1,9 @@
import { Session, Quest } from '@linux-odyssey/models'
import { createContainer, deleteContainer } from '../../containers/docker.js'
import SessionHandler from '../../game/sessionHandler.js'
import { matchedData } from 'express-validator'
import { Session } from '@linux-odyssey/models'
import {
createNewSession,
getOrCreateActiveSession,
} from '../../game/sessionManager.js'
function sessionSummary(session) {
return {
@@ -37,53 +40,14 @@ export async function getSessionList(req, res) {
}
}
async function deactivateSessions(user, quest) {
const sessions = await Session.find({
user,
quest,
status: 'active',
})
return Promise.all(
sessions.map((session) => {
return deleteContainer(session.containerId).then(() => {
session.status = 'inactive'
session.containerId = null
return session.save()
})
})
)
}
export async function createSession(req, res) {
const quest = await Quest.findById(req.body.quest_id)
if (!quest) {
res.status(400).json({ message: 'Quest not found.' })
return
}
// deactivate all active sessions
deactivateSessions(req.user._id, quest._id).catch((err) => {
console.error(err)
})
const { quest_id } = matchedData(req)
try {
const container = await createContainer(
`quest-${quest.id}-${req.user.username}-${Date.now()}`
)
const newSession = new Session({
user: req.user,
quest,
containerId: container.id,
})
const session = new SessionHandler(newSession)
session.addNewTasks()
await newSession.save()
res.status(201).json(sessionDetail(newSession))
const session = await createNewSession(req.user, quest_id)
res.status(201).json(sessionDetail(session))
} catch (err) {
console.error(err)
res.status(400).json({ message: err.message })
res.status(500).json({ message: err.message })
}
}
@@ -122,3 +86,14 @@ export async function deleteSessionById(req, res) {
res.status(500).json({ message: err.message })
}
}
export async function getOrCreateSession(req, res) {
const { quest_id } = matchedData(req)
try {
const session = await getOrCreateActiveSession(req.user, quest_id)
res.json(sessionDetail(session))
} catch (err) {
console.error(err)
res.status(500).json({ message: err.message })
}
}
+32 -8
View File
@@ -5,29 +5,38 @@ import enabledMethods from '../../auth/passport.js'
import {
register,
checkUsername,
checkSession,
logout,
socialLogin,
registerFromSession,
} from '../controllers/authController.js'
import {
checkUsernameValidators,
loginValidators,
registerFromSessionValidators,
registerValidators,
} from '../validators/authValidator.js'
import { authenticateRateLimit } from '../../middleware/rateLimiter.js'
const router = Router()
router.post(
'/login',
authenticateRateLimit,
loginValidators,
passport.authenticate('local', { failureMessage: true }),
(req, res) => {
res.json({ message: 'success' })
}
)
router.post('/register', register)
router.post('/register', authenticateRateLimit, registerValidators, register)
router.post('/logout', logout)
router.get('/check-username', checkUsername)
router.post('/logout', authenticateRateLimit, logout)
router.get('/check-username', checkUsernameValidators, (req, res) =>
res.json({ available: true })
)
router.get('/check-session', checkSession)
router.get('/available-methods', (req, res) => {
@@ -36,14 +45,29 @@ router.get('/available-methods', (req, res) => {
if (enabledMethods.google) {
router.get('/google', passport.authenticate('google'))
router.get('/google/callback', passport.authenticate('google'), socialLogin)
router.get(
'/google/callback',
authenticateRateLimit,
passport.authenticate('google'),
socialLogin
)
}
if (enabledMethods.github) {
router.get('/github', passport.authenticate('github'))
router.get('/github/callback', passport.authenticate('github'), socialLogin)
router.get(
'/github/callback',
authenticateRateLimit,
passport.authenticate('github'),
socialLogin
)
}
router.post('/register-from-session', registerFromSession)
router.post(
'/register-from-session',
authenticateRateLimit,
registerFromSessionValidators,
registerFromSession
)
export default router
+10 -3
View File
@@ -5,12 +5,19 @@ import quests from './quests.js'
import commands from './commands.js'
import authRouter from './auth.js'
import authRequired from '../../middleware/authRequired.js'
import config from '../../config.js'
const router = Router()
router.get('/', (req, res) => {
res.send('Hello API!')
})
if (!config.isProduction) {
router.get('/', (req, res) => {
res.send('Hello API!')
})
router.get('/ip', (req, res) => {
res.json({ ip: req.ip, forwarded: req.headers['x-forwarded-for'] })
})
}
router.use('/auth', authRouter)
router.use('/quests', quests)
+16 -1
View File
@@ -1,10 +1,25 @@
import { Router } from 'express'
import * as sessionController from '../controllers/sessionController.js'
import { sessionRateLimit } from '../../middleware/rateLimiter.js'
import {
createSessionValidator,
activeSessionValidator,
} from '../validators/sessionValidator.js'
const router = Router()
router.get('/', sessionController.getSessionList)
router.post('/', sessionController.createSession)
router.post(
'/',
createSessionValidator,
sessionRateLimit,
sessionController.createSession
)
router.post(
'/active',
activeSessionValidator,
sessionController.getOrCreateSession
)
router.get('/:id', sessionController.getSessionById)
router.delete('/:id', sessionController.deleteSessionById)
+3 -3
View File
@@ -35,7 +35,7 @@ async function connectClient(socket) {
}
const sessionId = socket.handshake.query.session_id
if (!sessionId) {
socket.send('Session ID not found.')
socket.emit('error', 'Session ID not found.')
socket.disconnect()
return
}
@@ -46,7 +46,7 @@ async function connectClient(socket) {
status: 'active',
})
if (!session) {
socket.send('Session not found.')
socket.emit('error', 'Session not found.')
socket.disconnect()
return
}
@@ -61,7 +61,7 @@ async function connectClient(socket) {
container = await getAndStartContainer(session.containerId)
stream = await attachContainer(container, { token })
} catch (err) {
socket.send(err.message)
socket.emit('error', 'Failed to start container.')
socket.disconnect()
return
}
@@ -0,0 +1,64 @@
import { check } from 'express-validator'
import { isValidUsername, isValidEmail } from '@linux-odyssey/utils'
import { User } from '@linux-odyssey/models'
import { passwordPolicy } from '@linux-odyssey/constants'
import { noError } from '../../middleware/validator.js'
const checkUsername = () =>
check('username')
.notEmpty()
.isString()
.custom((username) => {
if (!isValidUsername(username)) {
throw new Error('Invalid username')
}
return true
})
.trim()
const checkNewUsername = () =>
checkUsername().custom(async (username) => {
if (await User.exists({ username })) {
throw new Error('Username taken')
}
return true
})
const checkEmail = () => check('email').notEmpty().isString().isEmail().trim()
const checkNewEmail = () =>
checkEmail().custom(async (email) => {
if (await User.exists({ email })) {
throw new Error('Email taken')
}
return true
})
const checkPassword = () =>
check('password').notEmpty().isStrongPassword(passwordPolicy)
const checkLogin = () =>
check('username')
.isString()
.notEmpty()
.trim()
.custom((login) => {
if (!isValidUsername(login) && !isValidEmail(login)) {
throw new Error('Invalid login')
}
return true
})
export const registerValidators = [
checkNewUsername(),
checkNewEmail(),
checkPassword(),
noError,
]
export const registerFromSessionValidators = [checkNewUsername(), noError]
export const loginValidators = [checkLogin(), noError]
export const checkUsernameValidators = [checkNewUsername(), noError]
@@ -0,0 +1,8 @@
import { check } from 'express-validator'
import { noError } from '../../middleware/validator.js'
const checkQuestId = () =>
check('quest_id').notEmpty().isString().isLowercase().isLength({ max: 64 })
export const createSessionValidator = [checkQuestId(), noError]
export const activeSessionValidator = [checkQuestId(), noError]
+1 -1
View File
@@ -3,7 +3,7 @@ import config from '../config.js'
const jwtOptions = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.jwtSecret,
secretOrKey: config.secret,
}
function verifyJwt(jwtPayload, done) {
+1 -1
View File
@@ -8,7 +8,7 @@ async function verifyPassword(username, password, done) {
$or: [{ username }, { email: username }],
})
if (!user) {
return done(null, false, { message: 'Incorrect username.' })
return done(null, false, { message: 'Incorrect username or password.' })
}
const isPasswordValid = await bcrypt.compare(password, user.hashedPassword)
+9
View File
@@ -0,0 +1,9 @@
import { genSecret } from '../utils/env.js'
function setup() {
console.log('Setting up .env file...')
const secret = genSecret()
console.log(`SECRET_KEY=${secret}`)
}
setup()
+13 -1
View File
@@ -1,10 +1,21 @@
import validator from 'validator'
import { get } from './utils/env.js'
function getTrustProxies(key) {
if (!process.env[key]) {
return []
}
return process.env[key]
.split(',')
.map((p) => validator.trim(p))
.filter((p) => validator.isIPRange(p) || validator.isIP(p))
}
const config = {
host: get('HOST', 'localhost'),
port: get('PORT', 3000),
db: get('MONGO_URL', 'mongodb://localhost:27017/odyssey-test'),
jwtSecret: get('JWT_SECRET', 'secret'),
secret: get('SECRET_KEY', ''),
dockerNetwork: get('DOCKER_NETWORK', 'linux-odyssey-players'),
isProduction: process.env.NODE_ENV === 'production',
hostPwd: get('HOST_PWD', ''),
@@ -18,6 +29,7 @@ const config = {
clientID: get('GITHUB_CLIENT_ID', ''),
clientSecret: get('GITHUB_CLIENT_SECRET', ''),
},
trustedProxies: getTrustProxies('TRUSTED_PROXIES'),
}
config.baseUrl = get('BASE_URL', `http://${config.host}:${config.port}`)
+7 -5
View File
@@ -12,6 +12,7 @@ const containerOptions = {
OpenStdin: true,
StdinOnce: false,
HostConfig: {
NetworkMode: config.dockerNetwork,
Binds:
!config.isProduction && config.hostPwd
? [
@@ -22,20 +23,21 @@ const containerOptions = {
},
}
const network = engine.getNetwork(config.dockerNetwork)
// const network = engine.getNetwork(config.dockerNetwork)
export async function createContainer(name) {
const container = await engine.createContainer({
export function createContainer(name) {
return engine.createContainer({
...containerOptions,
name,
})
await network.connect({ Container: container.id })
return container
}
export async function getAndStartContainer(id) {
console.log(`Getting container: ${id}`)
const container = engine.getContainer(id)
if (!container) {
throw new Error(`Container ${id} not found`)
}
if (!(await container.inspect()).State.Running) {
await container.start()
}
+8 -2
View File
@@ -15,7 +15,13 @@ export async function removeExpired() {
await Promise.all(
sessions.map(async (session) => {
console.log('Expiring session: ', session._id, session.containerId)
await deleteContainer(session.containerId)
try {
await deleteContainer(session.containerId)
} catch (err) {
if (err.statusCode !== 404) {
throw err
}
}
if (session.status === 'active') {
session.status = 'inactive'
}
@@ -25,7 +31,7 @@ export async function removeExpired() {
)
console.log('Done checking for expired sessions')
} catch (err) {
console.error(err.message)
console.error(err)
}
}
+4
View File
@@ -6,6 +6,10 @@ export default class SessionHandler {
this.quest = session.quest
}
getSession() {
return this.session
}
getTask(stage) {
return this.session.tasks.find((t) => t.id === stage.id)
}
+64
View File
@@ -0,0 +1,64 @@
import { Session, Quest } from '@linux-odyssey/models'
import { createContainer, deleteContainer } from '../containers/docker.js'
import SessionHandler from './sessionHandler.js'
async function deactivateSessions(user, quest) {
const sessions = await Session.find({
user,
quest,
status: 'active',
})
return Promise.all(
sessions.map((session) => {
return deleteContainer(session.containerId)
.catch(console.error)
.finally(() => {
session.status = 'inactive'
session.containerId = null
return session.save()
})
})
)
}
export async function createNewSession(user, questId) {
const quest = await Quest.findById(questId)
if (!quest) {
throw new Error(`Quest ${questId} not found`)
}
// deactivate all active sessions
deactivateSessions(user._id, quest._id).catch((err) => {
console.error(err)
})
const container = await createContainer(
`quest-${quest.id}-${user.username}-${Date.now()}`
)
const sessionHandler = new SessionHandler(
new Session({
user,
quest,
containerId: container.id,
})
)
sessionHandler.addNewTasks()
const session = sessionHandler.getSession()
await session.save()
return session
}
export async function getOrCreateActiveSession(user, questId) {
const session = await Session.findOne({
user,
quest: questId,
status: 'active',
})
if (session) {
return session
}
return createNewSession(user, questId)
}
+18 -4
View File
@@ -12,11 +12,19 @@ import apiRouter from './api/routes/index.js'
import loadAndUpdateQuests from './utils/quest.js'
import config from './config.js'
import errorHandler from './middleware/error.js'
import { globalRateLimit } from './middleware/rateLimiter.js'
import sessionMiddleware from './middleware/session.js'
import expiryRemovalScheduler from './containers/expiryChecker.js'
import { createTestUser } from './utils/auth.js'
async function main() {
if (!config.secret) {
console.warn(
'No SECRET_KEY found in .env! To set up a persistent key, please run the setup script:'
)
console.warn('yarn setup')
process.exit(1)
}
try {
await connectDB(config.db)
console.log('Connected to MongoDB')
@@ -25,8 +33,6 @@ async function main() {
process.exit(1)
}
await createTestUser()
await loadAndUpdateQuests()
expiryRemovalScheduler()
@@ -36,7 +42,14 @@ async function main() {
const app = express()
const server = http.createServer(app)
socketServer(server)
app.use(errorHandler)
app.set('trust proxy', [
'loopback',
'linklocal',
'uniquelocal',
...config.trustedProxies,
])
console.log('Trusted proxies:', config.trustedProxies)
app.use(globalRateLimit)
app.use(sessionMiddleware)
app.use(passport.session())
app.use(
@@ -53,6 +66,7 @@ async function main() {
})
app.use('/api/v1', apiRouter)
app.use(errorHandler)
server.listen(config.port, config.host, () => {
console.log(`Server listening at ${config.baseUrl}`)
+1 -1
View File
@@ -1,5 +1,5 @@
// eslint-disable-next-line no-unused-vars
export default function errorHandler(err, req, res, next) {
console.error(err.stack) // Log error stack trace to the console
res.status(500).send({ message: err.message }) // Send error response
res.status(500).send({ message: 'Something broke!' }) // Send error response
}
+19
View File
@@ -0,0 +1,19 @@
import rateLimit from 'express-rate-limit'
export const globalRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100,
})
export const authenticateRateLimit = rateLimit({
windowMs: 2 * 60 * 1000,
max: 5,
})
export const sessionRateLimit = rateLimit({
windowMs: 10 * 1000,
max: 1,
keyGenerator: (req) => {
return req.user ? req.user._id : req.ip
},
})
+1 -1
View File
@@ -4,7 +4,7 @@ import MongoStore from 'connect-mongo'
import config from '../config.js'
export default session({
secret: config.jwtSecret,
secret: config.secret,
resave: false,
saveUninitialized: false,
rolling: true,
+13
View File
@@ -0,0 +1,13 @@
import { validationResult } from 'express-validator'
export function noError(req, res, next) {
const result = validationResult(req)
if (result.isEmpty()) {
next()
return
}
res.status(400).json({
message: 'Validation failed',
errors: result.array(),
})
}
+2 -15
View File
@@ -1,5 +1,4 @@
import jwt from 'jsonwebtoken'
import { User } from '@linux-odyssey/models'
import bcrypt from 'bcrypt'
import config from '../config.js'
@@ -7,23 +6,11 @@ export function hashPassword(password) {
return bcrypt.hash(password, 10)
}
export async function createTestUser() {
let user = await User.findOne({ username: 'alex' })
if (!user) {
user = new User({
username: 'alex',
email: 'alex@example.com',
})
user.hashedPassword = await hashPassword('123456')
await user.save()
}
}
export function genJWT(payload) {
return new Promise((resolve, reject) => {
jwt.sign(
payload,
config.jwtSecret,
config.secret,
{ expiresIn: config.containerExpiry },
(err, token) => {
if (err) {
@@ -38,7 +25,7 @@ export function genJWT(payload) {
export function verifyJWT(token) {
return new Promise((resolve, reject) => {
jwt.verify(token, config.jwtSecret, (err, decoded) => {
jwt.verify(token, config.secret, (err, decoded) => {
if (err) {
reject(err)
} else {
+6 -1
View File
@@ -1,4 +1,5 @@
/* eslint-disable import/prefer-default-export */
import crypto from 'crypto'
export function get(key, defaultValue) {
const value = process.env[key]
if (value != null) {
@@ -6,3 +7,7 @@ export function get(key, defaultValue) {
}
return defaultValue
}
export function genSecret() {
return crypto.randomUUID()
}
+45
View File
@@ -80,6 +80,37 @@ paths:
message:
type: string
/sessions/active:
post:
summary: 'Get or create an active session.'
tags:
- signed-in
description: 'This endpoint returns the latest active session for the user, or creates a new one if no active session exists.'
requestBody:
required: true
content:
application/json:
schema:
type: 'object'
required:
- quest_id
properties:
quest_id:
type: 'string'
description: 'The ID of the quest.'
example: 'helloworld'
responses:
200:
description: 'Return a active session.'
content:
application/json:
schema:
$ref: '#/components/schemas/Session'
400:
description: 'Bad Request. The request is invalid or malformed.'
500:
description: 'Internal Server Error.'
/sessions/{session_id}:
get:
tags:
@@ -488,6 +519,20 @@ paths:
description: Invalid request body
'401':
description: Unauthorized
/ip:
get:
summary: Get client IP address
responses:
'200':
description: IP address
content:
application/json:
schema:
type: object
properties:
ip:
type: string
example: '127.0.0.1'
components:
schemas:
+4 -5
View File
@@ -1,11 +1,10 @@
/// <reference types="cypress" />
describe('example helloworld app', () => {
beforeEach(() => {
cy.visit('/')
})
it('open login page', () => {
console.log('baseUrl', Cypress.config('baseUrl'))
cy.visit('/register')
cy.get('#username').type('alex')
cy.get('#password').type('123456{enter}')
cy.get('#email').type('alex@gmail.com')
cy.get('#password').type('123456As{enter}')
})
})
+18
View File
@@ -2695,6 +2695,11 @@ express-handlebars@^7.1.2:
graceful-fs "^4.2.11"
handlebars "^4.7.8"
express-rate-limit@^7.1.3:
version "7.1.3"
resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.1.3.tgz#0eae6c7733316f3d9403a71ad488e31e94ca0aa4"
integrity sha512-BDes6WeNYSGRRGQU8QDNwUnwqaBro28HN/TTweM3RlxXRHDld8RLoH7tbfCxAc0hamQyn6aL0KrfR45+ZxknYg==
express-session@^1.17.3:
version "1.17.3"
resolved "https://registry.yarnpkg.com/express-session/-/express-session-1.17.3.tgz#14b997a15ed43e5949cb1d073725675dd2777f36"
@@ -2709,6 +2714,14 @@ express-session@^1.17.3:
safe-buffer "5.2.1"
uid-safe "~2.1.5"
express-validator@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/express-validator/-/express-validator-7.0.1.tgz#435fac6b5fa838763f78eca05d2206317b92106e"
integrity sha512-oB+z9QOzQIE8FnlINqyIFA8eIckahC6qc8KtqLdLJcU3/phVyuhXH3bA4qzcrhme+1RYaCSwrq+TlZ/kAKIARA==
dependencies:
lodash "^4.17.21"
validator "^13.9.0"
express@^4.18.2:
version "4.18.2"
resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59"
@@ -6079,6 +6092,11 @@ v8-to-istanbul@^9.0.1:
"@types/istanbul-lib-coverage" "^2.0.1"
convert-source-map "^1.6.0"
validator@^13.11.0, validator@^13.9.0:
version "13.11.0"
resolved "https://registry.yarnpkg.com/validator/-/validator-13.11.0.tgz#23ab3fd59290c61248364eabf4067f04955fbb1b"
integrity sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==
vary@^1, vary@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"