diff --git a/.env b/.env index b03d9db..10fa2dc 100644 --- a/.env +++ b/.env @@ -1,7 +1,12 @@ NODE_ENV=development -PORT=8080 +PORT=8082 DB_HOST=127.0.0.1 DB_PORT=5432 DB_USER=user1 -DB_PASSWORD= -DB_NAME=database \ No newline at end of file +DB_PASSWORD=user1@123 +DB_NAME=database +HOST_IP=192.168.1.42 +OAUTH_CLIENT_ID=59687ac8d3fb4c928bfe3bf6f38c2524 +OAUTH_SECRET=e9ed54a2-2867-44a4-a053-d3048209a221 +OAUTH_REDIRECT_URL=http://localhost:8082/api/v1/auth/callback +SESSION_SECRET=session-secret \ No newline at end of file diff --git a/docker/postgresql/Dockerfile.postgis b/docker/postgresql/Dockerfile.postgis index 86a6265..4fa5f28 100644 --- a/docker/postgresql/Dockerfile.postgis +++ b/docker/postgresql/Dockerfile.postgis @@ -9,4 +9,4 @@ RUN apt-get update \ COPY init.sql /docker-entrypoint-initdb.d/ # Enable PostGIS extension -RUN echo "CREATE EXTENSION IF NOT EXISTS postgis;" >> /docker-entrypoint-initdb.d/init.sql +# RUN echo "CREATE EXTENSION IF NOT EXISTS postgis;" >> /docker-entrypoint-initdb.d/init.sql diff --git a/index.d.ts b/index.d.ts index f59f520..4a213f9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1 +1,8 @@ -declare module 'eslint-plugin-jest' \ No newline at end of file +declare module 'eslint-plugin-jest'; +import 'express-session'; + +declare module 'express-session' { + interface SessionData { + username?: string; + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 96eec8d..35bc816 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,22 +12,29 @@ "@types/express": "^4.17.21", "@types/node": "^20.16.11", "@types/swagger-ui-express": "^4.1.6", + "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-session": "^1.18.1", "helmet": "^7.2.0", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.13.0", "pg-hstore": "^2.3.4", "sequelize": "^6.37.4", "swagger-ui-express": "^5.0.1", + "uuid": "^10.0.0", "winston": "^3.15.0" }, "devDependencies": { "@eslint/js": "^9.12.0", "@types/cors": "^2.8.17", + "@types/express-session": "^1.18.0", "@types/jest": "^29.5.13", + "@types/jsonwebtoken": "^9.0.7", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-jest": "^28.8.3", @@ -1385,6 +1392,15 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.0.tgz", + "integrity": "sha512-27JdDRgor6PoYlURY+Y5kCakqp5ulC0kmf7y+QwaY+hv9jEFuQOThgkjyA53RP3jmKuBsH5GR6qEfFmvb8mwOA==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -1433,6 +1449,15 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", + "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1528,6 +1553,12 @@ "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.12.2", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", @@ -2573,6 +2604,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3100,6 +3136,14 @@ "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -3526,6 +3570,50 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.1.tgz", + "integrity": "sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -5018,6 +5106,46 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5089,6 +5217,36 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5101,6 +5259,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/logform": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/logform/-/logform-2.6.1.tgz", @@ -5409,6 +5572,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -5948,6 +6119,14 @@ } ] }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6276,6 +6455,14 @@ "node": ">= 10.0.0" } }, + "node_modules/sequelize/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", @@ -7020,6 +7207,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -7097,9 +7295,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } diff --git a/package.json b/package.json index fc62f67..659625a 100644 --- a/package.json +++ b/package.json @@ -25,22 +25,29 @@ "@types/express": "^4.17.21", "@types/node": "^20.16.11", "@types/swagger-ui-express": "^4.1.6", + "body-parser": "^1.20.3", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.1", + "express-session": "^1.18.1", "helmet": "^7.2.0", "http-status-codes": "^2.3.0", + "jsonwebtoken": "^9.0.2", "pg": "^8.13.0", "pg-hstore": "^2.3.4", "sequelize": "^6.37.4", "swagger-ui-express": "^5.0.1", + "uuid": "^10.0.0", "winston": "^3.15.0" }, "devDependencies": { "@eslint/js": "^9.12.0", "@types/cors": "^2.8.17", + "@types/express-session": "^1.18.0", "@types/jest": "^29.5.13", + "@types/jsonwebtoken": "^9.0.7", "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "eslint-plugin-jest": "^28.8.3", diff --git a/src/App.ts b/src/App.ts index 94cb0c0..008fa02 100755 --- a/src/App.ts +++ b/src/App.ts @@ -9,6 +9,7 @@ import swaggerDocument from '../swagger.json'; import registerRoutes from './routes'; import addErrorHandler from './middleware/error-handler'; import logger from './lib/logger'; +import session from 'express-session'; export default class App { public express: Application; @@ -58,6 +59,13 @@ export default class App { this.express.use( express.urlencoded({ limit: '100mb', extended: true }), ); + this.express.use( + session({ + secret: process.env.SESSION_SECRET, + resave: false, + saveUninitialized: false, + }), + ); // add multiple cors options as per your use const corsOptions = { origin: [ diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..04626c0 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,217 @@ +import { NextFunction, Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; +import { v4 as uuidv4 } from 'uuid'; +import BaseController from '../components/BaseController'; +import { RouteDefinition } from '../types/RouteDefinition'; +import { UserService } from '../components/user/UserService'; +import { ReasonPhrases, StatusCodes } from 'http-status-codes'; +import { UserAttributes } from '../database/models/User'; +import ApiError from '../abstractions/ApiError'; +import { ExtSession } from '../types/types'; + +interface AuthorizationCodeEntry { + client_id: string; + redirect_uri: string; + username: string; + scope: string; +} + +export default class OAuth2Controller extends BaseController { + private authorizationCodes: Record; + private jwtSecret: string; + public basePath = 'auth'; + private user: UserService; + + constructor() { + super(); + this.jwtSecret = process.env.OAUTH_SECRET; + this.authorizationCodes = {}; + this.user = new UserService(); + } + + public routes(): RouteDefinition[] { + return [ + { + path: '/register', + method: 'post', + handler: this.registerUser.bind(this), + }, + { + path: '/login', + method: 'post', + handler: this.loginUser.bind(this), + }, + { + path: '/authorize', + method: 'get', + handler: this.authorize.bind(this), + }, + { + path: '/token', + method: 'post', + handler: this.token.bind(this), + }, + { + path: '/callback', + method: 'get', + handler: this.callback.bind(this), + }, + { + path: '/userinfo', + method: 'get', + handler: this.userInfo.bind(this), + }, + ]; + } + + async registerUser(req: Request, res: Response, next: NextFunction) { + try { + const { name, country, email, username, password } = req.body; + if (!username || !password || !email || !name || !country) { + throw new ApiError( + ReasonPhrases.BAD_REQUEST, + StatusCodes.BAD_REQUEST, + ); + } + const user: UserAttributes = await this.user.create({ + username: username || email, + name, + country: country || '', + password, + email, + }); + res.locals.data = { + user, + }; + super.send(res, StatusCodes.CREATED); + } catch (err) { + next(err); + } + } + + private async loginUser( + req: Request & { session: ExtSession }, + res: Response, + next: NextFunction, + ) { + try { + const { username, password } = req.body; + + const user = await this.user.getByUserName(username); + if (!user || user?.password !== password) { + return res.status(401).send('Invalid credentials'); + } + req.session.username = username; + + res.json({ message: 'Login successful' }); + } catch (err) { + next(err); + } + } + + private authorize(req: Request & { session: ExtSession }, res: Response) { + if (!req.session.username) { + return res + .status(401) + .send('User not authenticated. Please log in first.'); + } + + const { response_type, client_id, redirect_uri, state } = req.query; + + const clientId = Array.isArray(client_id) + ? client_id[0] + : (client_id as string); + const redirectUri = Array.isArray(redirect_uri) + ? redirect_uri[0] + : (redirect_uri as string); + + if ( + !clientId || + !redirectUri || + !response_type || + response_type !== 'code' + ) { + return res.status(400).send('Invalid request parameters'); + } + + const username = req.session?.username; + + const authorizationCode = uuidv4(); + this.authorizationCodes[authorizationCode] = { + client_id: clientId.toString(), + redirect_uri: redirectUri.toString(), + username, + scope: 'openid profile', + }; + + const redirectUrl = `${redirect_uri}?code=${authorizationCode}${state ? `&state=${state}` : ''}`; + res.redirect(redirectUrl); + } + + private token(req: Request, res: Response) { + const { grant_type, code, redirect_uri, client_id } = req.body; + + if ( + grant_type !== 'authorization_code' || + !code || + !client_id || + !redirect_uri + ) { + return res.status(400).json({ error: 'invalid_request' }); + } + + const authCodeData = this.authorizationCodes[code]; + if ( + !authCodeData || + authCodeData.client_id !== client_id || + authCodeData.redirect_uri !== redirect_uri + ) { + return res.status(400).json({ error: 'invalid_grant' }); + } + + const accessToken = jwt.sign( + { sub: authCodeData.username, scope: authCodeData.scope }, + this.jwtSecret, + { expiresIn: '1h' }, + ); + + res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_in: 3600, + }); + } + + private async userInfo(req: Request, res: Response) { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) return res.status(401).send('Unauthorized'); + + try { + const decoded = jwt.verify(token, this.jwtSecret); + const username = decoded.sub.toString(); + const user = await this.user.getByUserName(username); + res.json({ + sub: username, + username: user.username, + name: user.name, + email: user.email, + country: user.country, + id: user.id, + }); + } catch (error) { + res.status(401).send('Invalid token'); + } + } + + private callback(req: Request, res: Response) { + const { code, state } = req.query; + + if (!code) { + return res.status(400).send('Authorization code is missing'); + } + + res.send( + `Authorization code received: ${code}. State: ${state ? state : 'No state'}`, + ); + } +} diff --git a/src/components/user/UserController.ts b/src/components/user/UserController.ts new file mode 100755 index 0000000..9e5e248 --- /dev/null +++ b/src/components/user/UserController.ts @@ -0,0 +1,178 @@ +import { NextFunction, Request, Response } from 'express'; +import { ReasonPhrases, StatusCodes } from 'http-status-codes'; +import BaseController from '../BaseController'; +import { UserService } from './UserService'; +import { UserAttributes } from '../../database/models/User'; +import ApiError from '../../abstractions/ApiError'; +import { RouteDefinition } from '../../types/RouteDefinition'; + +/** + * User controller + */ +export default class UserController extends BaseController { + private user: UserService; + public basePath = 'users'; + + constructor() { + super(); + this.user = new UserService(); + } + + /** + * The routes method returns an array of route definitions for CRUD operations + * (GET, POST, PUT, DELETE) on enquiries, + * with corresponding handlers bound to the controller instance. + */ + public routes(): RouteDefinition[] { + return [ + { path: '/', method: 'get', handler: this.getUsers.bind(this) }, + { + path: '/:id', + method: 'get', + handler: this.getUser.bind(this), + }, + { + path: '/', + method: 'post', + handler: this.createUser.bind(this), + }, + { + path: '/:id', + method: 'put', + handler: this.updateUser.bind(this), + }, + { path: '/:id', method: 'delete', handler: this.delete.bind(this) }, + ]; + } + + /** + * + * @param req + * @param res + * @param next + */ + public async getUsers( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const users: UserAttributes[] = await this.user.getAll(); + res.locals.data = users; + // call base class method + this.send(res); + } catch (err) { + next(err); + } + } + + /** + * + * @param req + * @param res + * @param next + */ + public async getUser( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const id = req.params.id; + const user: UserAttributes = await this.user.getById(id); + res.locals.data = user; + // call base class method + this.send(res); + } catch (err) { + next(err); + } + } + + /** + * + * @param req + * @param res + * @param next + */ + public async updateUser( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const id = req.params.id; + const { body } = req; + const user: UserAttributes = await this.user.update( + id, + body, + ); + res.locals.data = { + user, + }; + // call base class method + this.send(res); + } catch (err) { + next(err); + } + } + + /** + * + * @param req + * @param res + * @param next + */ + public async createUser( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { name, country, email, username, password } = req.body; + if (!name && !country) { + throw new ApiError( + ReasonPhrases.BAD_REQUEST, + StatusCodes.BAD_REQUEST, + ); + } + const user: UserAttributes = await this.user.create({ + username: username || email, + name, + country: country || "", + password, + email, + }); + res.locals.data = { + user, + }; + // call base class method + super.send(res, StatusCodes.CREATED); + } catch (err) { + next(err); + } + } + + /** + * + * @param req + * @param res + * @param next + */ + public async delete( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const id = req.params.id; + const status: boolean = await this.user.delete(id); + res.locals.data = { + status, + }; + // call base class method + this.send(res); + } catch (err) { + next(err); + } + } +} diff --git a/src/components/user/UserService.ts b/src/components/user/UserService.ts new file mode 100755 index 0000000..5164793 --- /dev/null +++ b/src/components/user/UserService.ts @@ -0,0 +1,88 @@ +import { + User, + UserAttributes, + UserCreationAttributes, +} from '../../database/models/User'; +import logger from '../../lib/logger'; +import ApiError from '../../abstractions/ApiError'; +import { StatusCodes } from 'http-status-codes'; + +export class UserService { + async getAll(): Promise { + try { + const users = await User.findAll(); + return users; + } catch (error) { + logger.error(error); + throw error; + } + } + + async getById(id: string | number): Promise { + try { + const user = await User.findByPk(id); + if (!user) { + throw new ApiError('User not found', StatusCodes.NOT_FOUND); + } + return user; + } catch (error) { + logger.error(error); + throw error; + } + } + + async getByUserName(username: string | number): Promise { + try { + const user = await User.findOne({ + where: { username: username }, + }); + if (!user) { + throw new ApiError('User not found', StatusCodes.NOT_FOUND); + } + return user; + } catch (error) { + logger.error(error); + throw error; + } + } + + async update( + id: string | number, + payload: Partial, + ): Promise { + try { + const user = await User.findByPk(id); + if (!user) { + throw new ApiError('User not found', StatusCodes.NOT_FOUND); + } + const updatedUser = await user.update(payload); + return updatedUser; + } catch (error) { + logger.error(error); + throw error; + } + } + + async create(payload: UserCreationAttributes): Promise { + try { + const user = await User.create(payload); + return user; + } catch (error) { + logger.error(error); + throw error; + } + } + + async delete(id: string | number): Promise { + try { + const deletedUserCount = await User.destroy({ + where: { id }, + }); + + return !!deletedUserCount; + } catch (error) { + logger.error(error); + throw error; + } + } +} diff --git a/src/database/models/Client.ts b/src/database/models/Client.ts new file mode 100644 index 0000000..3ac4b6e --- /dev/null +++ b/src/database/models/Client.ts @@ -0,0 +1,55 @@ +import { DataTypes, Model, Optional, UUIDV4 } from 'sequelize'; +import sequelize from '../index'; + +interface ClientAttributes { + id: string; + client_id: string; + client_secret: string; + redirect_uri: string; +} + +type ClientCreationAttributes = Optional; + +class Client + extends Model + implements ClientAttributes +{ + public id!: string; + public client_id!: string; + public client_secret!: string; + public redirect_uri!: string; + + public readonly createdAt!: Date; + public readonly updatedAt!: Date; +} + +Client.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + client_id: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + client_secret: { + type: DataTypes.STRING, + allowNull: false, + }, + redirect_uri: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + sequelize, + modelName: 'Client', + tableName: 'Client', + timestamps: true, + }, +); + +export { Client, ClientAttributes, ClientCreationAttributes }; diff --git a/src/database/models/User.ts b/src/database/models/User.ts new file mode 100644 index 0000000..aa57104 --- /dev/null +++ b/src/database/models/User.ts @@ -0,0 +1,66 @@ +import { DataTypes, Model, Optional, UUIDV4 } from 'sequelize'; +import sequelize from '../index'; + +interface UserAttributes { + id: string; + name: string; + password: string; + username: string; + email: string; + country?: string; +} + +type UserCreationAttributes = Optional; + +class User + extends Model + implements UserAttributes +{ + public id!: string; + public name!: string; + public country!: string; + public email!: string; + public username!: string; + public password!: string; + + public readonly createdAt!: Date; + public readonly updatedAt!: Date; +} + +User.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + username: { + type: DataTypes.STRING(100), + allowNull: false, + }, + name: { + type: DataTypes.STRING(100), + allowNull: false, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + }, + country: { + type: DataTypes.STRING(100), + allowNull: false, + }, + email: { + type: DataTypes.STRING(100), + allowNull: false, + } + }, + { + sequelize, + modelName: 'User', + tableName: 'User', + timestamps: true, + }, +); + +export { User, UserAttributes, UserCreationAttributes }; diff --git a/src/routes.ts b/src/routes.ts index de6f6d0..97d407c 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -3,6 +3,8 @@ import EnquiryController from './components/enquiry/EnquiryController'; import SystemStatusController from './components/system-status/SystemStatusController'; import { RouteDefinition } from './types/RouteDefinition'; import logger from './lib/logger'; +import UserController from './components/user/UserController'; +import OAuth2Controller from './auth'; /** * @@ -51,7 +53,9 @@ export default function registerRoutes(): Router { // Define an array of controller objects const controllers = [ new SystemStatusController(), + new OAuth2Controller(), new EnquiryController(), + new UserController() ]; // Dynamically register routes for each controller diff --git a/src/types/types.ts b/src/types/types.ts new file mode 100644 index 0000000..34caeb5 --- /dev/null +++ b/src/types/types.ts @@ -0,0 +1,5 @@ +import { Session } from "express-session"; + +export interface ExtSession extends Session { + username?: string; +} \ No newline at end of file