diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..5f5f94b --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,23 @@ +FROM node:18-alpine + +WORKDIR /nork + +COPY ../../nork . + +RUN npm install + +RUN npm run build + +RUN npm link + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . . + +EXPOSE 3000 + +CMD ["npm", "start"] \ No newline at end of file diff --git a/api/docker-compose.yaml b/api/docker-compose.yaml new file mode 100644 index 0000000..c95d2db --- /dev/null +++ b/api/docker-compose.yaml @@ -0,0 +1,19 @@ +services: + mongo: + image: mongo + restart: always + environment: + MONGO_INITDB_ROOT_USERNAME: root + MONGO_INITDB_ROOT_PASSWORD: root + ports: + - 27017:27017 + mongo-express: + image: mongo-express + restart: always + ports: + - 8081:8081 + environment: + ME_CONFIG_MONGODB_ADMINUSERNAME: root + ME_CONFIG_MONGODB_ADMINPASSWORD: root + ME_CONFIG_MONGODB_URL: mongodb://root:root@mongo:27017/ + ME_CONFIG_BASICAUTH: false diff --git a/api/package.json b/api/package.json index da6e875..fb4276d 100644 --- a/api/package.json +++ b/api/package.json @@ -19,22 +19,29 @@ "author": "Filip Rojek", "license": "MIT", "dependencies": { + "bcrypt": "^5.1.1", "colors": "1.4.0", "dotenv": "^16.4.5", "express": "^4.19.2", "fs-extra": "^10.0.0", "inquirer": "^8.1.2", + "jsonwebtoken": "^9.0.2", + "mongoose": "^8.3.3", "morgan": "^1.10.0", "pad": "^3.2.0", - "path": "^0.12.7" + "path": "^0.12.7", + "yup": "^1.4.0", + "yup-password": "^0.4.0" }, "devDependencies": { "@biomejs/biome": "1.7.1", + "@types/bcrypt": "^5.0.2", "@types/chai": "^4.2.22", "@types/express": "^4.17.21", "@types/fs-extra": "^9.0.13", "@types/inquirer": "^8.1.3", "@types/jest": "^29.5.12", + "@types/jsonwebtoken": "^9.0.6", "@types/mocha": "^9.0.0", "@types/morgan": "^1.9.9", "@types/shelljs": "^0.8.11", @@ -46,6 +53,7 @@ "http": "^0.0.1-security", "jest": "^29.7.0", "mocha": "^9.1.3", + "mongodb-memory-server": "^9.2.0", "npm-run-all": "^4.1.5", "prettier": "^2.7.1", "rimraf": "^3.0.2", diff --git a/api/src/controllers/authController.ts b/api/src/controllers/authController.ts index 2e39bf5..ad26bb9 100644 --- a/api/src/controllers/authController.ts +++ b/api/src/controllers/authController.ts @@ -1,5 +1,81 @@ -import { Request, Response } from "express"; +import { Request, Response } from 'express'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; +import env from '../config/environment'; +import Docs from '../services/docsService'; +import User from '../models/User'; +import {Log} from 'nork' +import { IUser, signupExam, ISignin, signinExam } from '../validators/authValidator'; -export function signup_post(req: Request, res: Response) { - res.send("logged in"); +new Docs('user', 'signup', '/api/v1/auth/signup', 'POST', 'user signup api', undefined, signupExam, 'status object'); +export async function signup_post(req: Request, res: Response) { + try { + const payload: IUser = req.body; + + payload.password = await bcrypt.hash(payload.password, 12); + const user = new User(payload); + await user.save(); + + res.status(201).json(Log.info(201, 'user was successfully signed up')); + } catch (err: any) { + if (err.code == 11000) { + res.status(400).json(Log.error(400, 'this user already exists')); + return; + } + Log.error(500, err); + res.status(500).json(Log.error(500, 'something went wrong')); + } } + +new Docs('user', 'signin', '/api/v1/auth/signin', 'POST', 'user signin api', undefined, signinExam, 'status object'); +export async function signin_post(req: Request, res: Response) { + try { + const payload: ISignin = req.body; + + const user = await User.findOne({ email: payload.email }); + if (!user) { + res.cookie('jwt', '', { httpOnly: true, maxAge: 0 }); + res.cookie('auth', false, { httpOnly: false, maxAge: 0 }); + res.status(401).json(Log.error(401, 'email or password is wrong')); + return; + } + + if (await bcrypt.compare(payload.password, user.password)) { + const maxAge = 3 * 24 * 60 * 60; // 3 days in seconds + const createToken = (id: any) => { + return jwt.sign({ id }, env.JWT_SECRET, { + expiresIn: maxAge + }); + }; + + const token = createToken(user._id); + res.cookie('jwt', token, { httpOnly: true, maxAge: maxAge * 1000 }); + res.cookie('auth', true, { httpOnly: false, maxAge: maxAge * 1000 }); + + res.json(Log.info(200, 'user is logged in')); + return; + } + + res.cookie('jwt', '', { httpOnly: true, maxAge: 0 }); + res.cookie('auth', false, { httpOnly: false, maxAge: 0 }); + res.status(401).json(Log.error(401, 'email or password is wrong')); + } catch (err: any) { + Log.error(500, err); + res.status(500).json(Log.error(500, 'something went wrong')); + } +} + +new Docs('user', 'logout', '/api/v1/auth/logout', 'POST', 'user logout api', undefined, {}, 'status object'); +export function logout_post(req: Request, res: Response) { + res.cookie('jwt', '', { httpOnly: true, maxAge: 0 }); + res.cookie('auth', false, { httpOnly: false, maxAge: 0 }); + res.json(Log.info(200, 'user was logged out')); +} + +new Docs('user', 'status', '/api/v1/auth/status', 'GET', 'user login status api', undefined, undefined, 'status code | user object'); +export function status_get(req: Request, res: Response) { + let userObject = res.locals.user; + userObject.password = undefined; + userObject.__v = undefined; + res.status(200).json(Log.info(200, 'user is logged in', userObject)); +} \ No newline at end of file diff --git a/api/src/middlewares/validateRequest.ts b/api/src/middlewares/validateRequest.ts new file mode 100644 index 0000000..d879a04 --- /dev/null +++ b/api/src/middlewares/validateRequest.ts @@ -0,0 +1,15 @@ +import { Request, Response, NextFunction } from 'express'; +import { object, AnySchema } from 'yup'; +import { Log } from 'nork'; + +const validate = (schema: AnySchema) => async (req: Request, res: Response, next: NextFunction) => { + try { + await schema.validate(req.body); + + next(); + } catch (err: any) { + return res.status(400).json(Log.error(400, 'validation error', err)); + } +}; + +export default validate; diff --git a/api/src/models/User.ts b/api/src/models/User.ts new file mode 100644 index 0000000..9f0a3ae --- /dev/null +++ b/api/src/models/User.ts @@ -0,0 +1,26 @@ +import path from 'path'; +import { Schema, model } from 'mongoose'; +import { IUser } from '../validators/authValidator'; + +const schema = new Schema( + { + username: { + type: String, + required: true + }, + email: { + type: String, + required: true, + unique: true + }, + password: { + type: String, + required: true + } + }, + { + timestamps: true + } +); + +export default model(path.basename(__filename).split('.')[0], schema); diff --git a/api/src/routes/authRoutes.ts b/api/src/routes/api_v1.ts similarity index 80% rename from api/src/routes/authRoutes.ts rename to api/src/routes/api_v1.ts index ee10c77..c8d274c 100644 --- a/api/src/routes/authRoutes.ts +++ b/api/src/routes/api_v1.ts @@ -1,6 +1,7 @@ import { Router } from "express"; import * as authController from "../controllers/authController"; -//import authValidator from "../validators/authValidator"; +import validate from '../middlewares/validateRequest' +import * as AuthVal from '../validators/authValidator' //import handleValidation from "../middlewares/handleValidation"; //import { requireAuth } from "../middlewares/authMiddleware"; @@ -8,7 +9,7 @@ const router = Router(); //const mws = [requireAuth, handleValidation.handleValidationError]; -router.post("/signup", authController.signup_post); +router.post("/auth/signup",validate(AuthVal.signup) , authController.signup_post); //router.post( // "/login", diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index acf0269..05b7ef8 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -1,9 +1,9 @@ import { Request, Response, Router } from "express"; import path from "path"; -import authRoutes from "./authRoutes"; +import api_v1 from "./api_v1"; export const router = Router(); -router.use("/api/auth", authRoutes); +router.use("/api/v1", api_v1); //router.get("*", (req: Request, res: Response) => { // res.sendFile(path.join(__dirname, "../views/index.html")); diff --git a/api/src/server.ts b/api/src/server.ts index fbc1287..aac0ef0 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -1,26 +1,40 @@ import http from "http"; import { app } from "./app"; import env from "./config/environment"; -//const env = { -// APP_PORT: 8080, -// APP_HOSTNAME: "127.0.0.1", -//}; +import mongoose from 'mongoose' // TODO: dopsat nork module pro db import { Log } from "nork"; -//import database from './config/database' + const port: number = env.APP_PORT || 8080; const hostname: string = env.APP_HOSTNAME || "localhost"; + export const server = http.createServer(app); // Server -export function runServer(): void { - server.listen(port, hostname, () => { - Log.info(200, `Server is listening on http://${hostname}:${port}`); - }); -} - -//if (!env.NORK.database) { -runServer(); +//export function runServer(): void { +// server.listen(port, hostname, () => { +// Log.info(200, `Server is listening on http://${hostname}:${port}`); +// }); +//} +// +////if (!env.NORK.database) { +//runServer(); //} else { // const db_connection = database() // runServer() //} + + + +(async () => { + if (!process.env.DOCS_GEN) { + try { + await mongoose.connect(env.DB_URI); + Log.info(200, 'connected to db'); + server.listen(port, () => { + Log.info(200, `Server is listening on http://localhost:${port}`); + }); + } catch (err: any) { + Log.error(500, err); + } + } +})(); diff --git a/api/src/services/docsService.ts b/api/src/services/docsService.ts new file mode 100644 index 0000000..a10c744 --- /dev/null +++ b/api/src/services/docsService.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import path from 'path'; + +export type HttpMethods = 'POST' | 'GET' | 'PUT' | 'DELETE'; +export type ApiResponse = 'status object' | Object | string; + +export class Docs { + private name: string; + private operation: string; + private route: string; + private method: HttpMethods; + private parameters?: Object[]; + private description?: string; + private body?: Object; + private response?: ApiResponse; + + public constructor(name: string, operation: string, route: string, method: HttpMethods, description?: string, parameters?: Object[], body?: Object, response?: ApiResponse) { + this.name = name; + this.operation = operation; + this.route = route; + this.method = method; + description ? (this.description = description) : null; + parameters ? (this.parameters = parameters) : null; + body ? (this.body = body) : null; + response ? (this.response = response) : null; + + this.generate(); + } + + private generate(): boolean { + if (!process.env.DOCS_GEN) { + return false; + } + + const jsonPath = path.join(__dirname, '../public/api.json'); + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json')).toString()); + + const jsonAPI = () => JSON.parse(fs.readFileSync(jsonPath).toString()); + const genJsonAPI = () => fs.writeFileSync(jsonPath, JSON.stringify({ version: pkg.version, endpoints: {} })); + + if (!fs.existsSync(path.join(__dirname, '../public'))) { + fs.mkdirSync(path.join(__dirname, '../public')); + } + + if (!fs.existsSync(jsonPath)) { + genJsonAPI(); + } + + if (jsonAPI().version != pkg.version) { + genJsonAPI(); + } + + const data = jsonAPI(); + data.endpoints[this.name] ? undefined : (data.endpoints[this.name] = {}); + data.endpoints[this.name][this.operation] = this; + fs.writeFileSync(jsonPath, JSON.stringify(data)); + + return true; + } +} + +export default Docs; \ No newline at end of file diff --git a/api/src/utils/test_mariadb.ts b/api/src/utils/test_mariadb.ts deleted file mode 100644 index ce8d355..0000000 --- a/api/src/utils/test_mariadb.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Sequelize } from 'sequelize'; - -let sequelize: Sequelize | null = null; - -const connectDB = async () => { - sequelize = new Sequelize({ - dialect: 'mariadb', - host: 'localhost', - username: 'your_username', - password: 'your_password', - database: 'your_database', - }); - - try { - await sequelize.authenticate(); - console.log('Connection to the database has been established successfully.'); - } catch (error) { - console.error('Unable to connect to the database:', error); - } -}; - -const dropDB = async () => { - if (sequelize) { - try { - await sequelize.drop(); - console.log('Database dropped successfully.'); - } catch (error) { - console.error('Error dropping database:', error); - } finally { - await sequelize.close(); - } - } -}; - -const dropTables = async () => { - if (sequelize) { - try { - await sequelize.sync({ force: true }); - console.log('All tables dropped successfully.'); - } catch (error) { - console.error('Error dropping tables:', error); - } finally { - await sequelize.close(); - } - } -}; - -export { connectDB, dropDB, dropTables }; diff --git a/api/src/utils/test_mongodb.ts b/api/src/utils/test_mongodb.ts new file mode 100644 index 0000000..3e0b35e --- /dev/null +++ b/api/src/utils/test_mongodb.ts @@ -0,0 +1,30 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); + +let mongo: any = null; + +const connectDB = async () => { + mongo = await MongoMemoryServer.create({ binary: { os: { os: 'linux', dist: 'ubuntu', release: '18.04' } } }); // TODO: check that host OS is Void Linux, else remove the argument + const uri = mongo.getUri(); + + await mongoose.connect(uri); +}; + +const dropDB = async () => { + if (mongo) { + await mongoose.connection.dropDatabase(); + await mongoose.connection.close(); + await mongo.stop(); + } +}; + +const dropCollections = async () => { + if (mongo) { + const collections = await mongoose.connection.db.collections(); + for (let collection of collections) { + await collection.remove(); + } + } +}; + +export { connectDB, dropDB, dropCollections }; \ No newline at end of file diff --git a/api/src/validators/authValidator.ts b/api/src/validators/authValidator.ts new file mode 100644 index 0000000..0b49f76 --- /dev/null +++ b/api/src/validators/authValidator.ts @@ -0,0 +1,34 @@ +import * as yup from 'yup'; +import YupPassword from 'yup-password'; +YupPassword(yup); +import { Schema } from 'mongoose'; + +interface mongooseAddition { + _id?: Schema.Types.ObjectId; + createdAt?: Schema.Types.Date; + updatedAt?: Schema.Types.Date; +} + +// SignUp +export const signup = yup.object({ + username: yup.string().required(), + email: yup.string().email().required(), + password: yup.string().min(8).minLowercase(1).minUppercase(1).minNumbers(1).required() +}); +export interface IUser extends yup.InferType, mongooseAddition {} +export const signupExam: IUser = { + username: 'testuser', + email: 'text@example.com', + password: 'Test1234' +}; + +// SignIn +export const signin = yup.object({ + email: yup.string().email().required(), + password: yup.string().min(8).minLowercase(1).minUppercase(1).minNumbers(1).required() +}); +export interface ISignin extends yup.InferType, mongooseAddition {} +export const signinExam: ISignin = { + email: 'text@example.com', + password: 'Test1234' +}; \ No newline at end of file diff --git a/api/tests/auth.test.ts b/api/tests/auth.test.ts index a8147a7..9555863 100644 --- a/api/tests/auth.test.ts +++ b/api/tests/auth.test.ts @@ -1,17 +1,227 @@ -import request from "supertest" -import {server as app} from "../server" +import supertest from 'supertest'; +import { app } from '../src/app'; +import { connectDB, dropDB, dropCollections } from '../src/utils/test_mongodb'; -describe('Auth API Endpoints', () => { - afterAll((done) => { - app.close(done); - }) +const request = supertest(app); - it('should signup new user', async () => { - const res = await request(app) - .post('/api/auth/signup') - .send({}); +export const getJWT = async () => { + try { + const resReg: any = await request.post('/api/v1/auth/signup').send({ + email: 'test@example.local', + password: 'admin1234', + username: 'Test Test' + }); - expect(res.statusCode).toEqual(200) - - }) -}) + const resLog: any = await request.post('/api/auth/login').send({ + email: 'test@example.local', + password: 'admin1234' + }); + if (resLog.statusCode != 200) throw 'error while logging in'; + + const body = JSON.parse(resLog.text); + return Promise.resolve(body.data.jwt); + } catch (err: any) { + console.log(err); + return err; + } +}; + +/** + * + * @returns JWT cookie + */ +export async function login(): Promise { + const res = await request.post('/api/v1/auth/signin').send({ + email: 'thisistest@host.local', + password: 'Admin1234' + }); + return res.headers['set-cookie']; +} + +export async function signup(): Promise { + const res = await request.post('/api/v1/auth/signup').send({ + email: 'thisistest@host.local', + password: 'Admin1234', + username: 'Test Test' + }); + + if (res.statusCode == 201) return true; + return false; +} + +describe('POST /api/v1/auth/signup', () => { + describe('should drop validation error', () => { + it('should drop 400 (empty request))', async () => { + const res: any = await request.post('/api/v1/auth/signup').send({}); + expect(res.statusCode).toBe(400); + }); + + it('should drop 400 (email))', async () => { + const res: any = await request.post('/api/v1/auth/signup').send({ + email: '', + username: 'User Admin', + password: 'Admin1234' + }); + console.log(res) + const body = JSON.parse(res.text); + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('email'); + }); + + it('should drop 400 (username))', async () => { + const res: any = await request.post('/api/v1/auth/signup').send({ + email: 'admin@localhost.local', + username: '', + password: 'Admin1234' + }); + const body = JSON.parse(res.text); + + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('username'); + }); + it('should drop 400 (password))', async () => { + const res: any = await request.post('/api/v1/auth/signup').send({ + email: 'admin@localhost.local', + username: 'User Admin', + password: '' + }); + const body = JSON.parse(res.text); + + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('password'); + }); + it('should drop 400 (password - min 8 chars', async () => { + const res = await request.post('/api/v1/auth/signup').send({ + email: 'admin@localhost.local', + username: 'User Admin', + password: 'Admin12' + }); + const body = JSON.parse(res.text); + + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('password'); + }); + it('should drop 400 (password - min 1 number', async () => { + const res = await request.post('/api/v1/auth/signup').send({ + email: 'admin@localhost.local', + username: 'User Admin', + password: 'Adminadmin' + }); + const body = JSON.parse(res.text); + + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('password'); + }); + it('should drop 400 (password - min 1 uppercase', async () => { + const res = await request.post('/api/v1/auth/signup').send({ + email: 'admin@localhost.local', + username: 'User Admin', + password: 'admin1234' + }); + const body = JSON.parse(res.text); + + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('password'); + }); + }); + + test('should register an user', async () => { + const res: any = await request.post('/api/v1/auth/signup').send({ + email: 'thisistest@host.local', + password: 'Admin1234', + username: 'Test Test' + }); + + expect(res.statusCode).toBe(201); + }); +}); + +/* +describe('POST /api/v1/auth/signin', () => { + const url = '/api/v1/auth/signin'; + + describe('should drop an validation error', () => { + it('should drop 400 (empty)', async () => { + const res = await request.post(url).send(); + + expect(res.statusCode).toBe(400); + }); + + it('should drop 400 (email)', async () => { + const res = await request.post(url).send({ + password: 'Admin1234' + }); + const body = JSON.parse(res.text); + + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('email'); + }); + + it('should drop 400 (password)', async () => { + const res = await request.post(url).send({ + email: 'thisistest@host.local' + }); + const body = JSON.parse(res.text); + + expect(res.statusCode).toBe(400); + expect(body.data.path).toBe('password'); + }); + }); + + test('should drop 401', async () => { + const res = await request.post(url).send({ + email: 'thisistest@host.local', + password: 'Test12365465132' + }); + expect(res.statusCode).toBe(401); + }); + + test('should login an user', async () => { + const res: any = await request.post(url).send({ + email: 'thisistest@host.local', + password: 'Admin1234' + }); + + expect(res.statusCode).toBe(200); + }); +}); + +/** + * Throws errors idk + +describe('POST /api/v1/auth/logout', () => { + const url = '/api/v1/auth/logout'; + test('should drop 401 error', async () => { + const res = await request.post(url).send({}); + expect(res.statusCode).toBe(401); + }); + + test('should logout an user', async () => { + const jwt = await login(); + const res = await request.post(url).set('Cookie', jwt).send({}); + + res.headers['set-cookie'].forEach((el: any) => { + if (el.split('=')[0] == 'jwt') { + expect(Number(el.split('=')[2][0])).toBe(0); + } + }); + + expect(res.statusCode).toBe(200); + }); +}); + */ + +/* +describe('GET /api/v1/auth/status', () => { + const url = '/api/v1/auth/status'; + test('should return login status 401', async () => { + const res = await request.get(url).send(); + expect(res.statusCode).toBe(401); + }); + test('should return login status 200', async () => { + const jwt = await login(); + const res = await request.get(url).set('Cookie', jwt).send(); + expect(res.statusCode).toBe(200); + }); +}); +*/