From b3128ede9a04d8a0d3c09a3cb8d50a42aa7acbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Babi=C4=8D?= Date: Tue, 1 Oct 2019 17:15:27 +0200 Subject: [PATCH] rework the folder structure to fractal pattern --- package.json | 77 +++++++++++++------------- src/User/UserResolver.ts | 62 --------------------- src/app.ts | 45 ++++++--------- src/{User => app}/UserResolver.spec.ts | 48 ++++++++-------- src/app/UserResolver.ts | 54 ++++++++++++++++++ src/app/schema.ts | 39 +++++++++++++ src/app/userResolver/Tokens.ts | 8 +++ src/{ => app/userResolver}/User.ts | 22 ++++---- src/app/userResolver/auth.ts | 56 +++++++++++++++++++ src/auth.ts | 59 -------------------- src/schema.ts | 24 -------- 11 files changed, 249 insertions(+), 245 deletions(-) delete mode 100644 src/User/UserResolver.ts rename src/{User => app}/UserResolver.spec.ts (70%) create mode 100644 src/app/UserResolver.ts create mode 100644 src/app/schema.ts create mode 100644 src/app/userResolver/Tokens.ts rename src/{ => app/userResolver}/User.ts (50%) create mode 100644 src/app/userResolver/auth.ts delete mode 100644 src/auth.ts delete mode 100644 src/schema.ts diff --git a/package.json b/package.json index 1207b10..8c0ef6a 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,41 @@ { - "name": "pcbizr", - "version": "0.0.1", - "description": "", - "main": "src/app.js", - "scripts": { - "dev": "ts-node-dev --respawn src/app.ts", - "test": "jest -i --watch", - "gen:key": "ssh-keygen -t rsa -b 2048 -f src/utils/keys/jwtRS256.key && openssl rsa -in src/utils/keys/jwtRS256.key -pubout -outform PEM -out src/utils/keys/jwtRS256.key.pub" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "apollo-server": "^2.9.3", - "apollo-server-express": "^2.9.4", - "argon2": "^0.24.1", - "dotenv": "^8.1.0", - "express": "^4.17.1", - "graphql": "^14.5.4", - "jsonwebtoken": "^8.5.1", - "pg": "^7.12.1", - "reflect-metadata": "^0.1.13", - "type-graphql": "^0.17.5", - "typeorm": "^0.2.18" - }, - "devDependencies": { - "@types/express": "^4.17.1", - "@types/graphql": "^14.5.0", - "@types/jest": "^24.0.18", - "@types/js-cookie": "^2.2.2", - "@types/jsonwebtoken": "^8.3.3", - "@types/node": "^12.7.5", - "class-transformer": "^0.2.3", - "jest": "^24.9.0", - "ts-jest": "^24.1.0", - "ts-node-dev": "^1.0.0-pre.42", - "typescript": "^3.6.3" - } + "name": "pcbizr", + "version": "0.0.1", + "description": "", + "main": "src/app.js", + "scripts": { + "dev": "ts-node-dev --respawn src/app.ts", + "test": "jest -i --coverage", + "test:watch": "jest -i --watch", + "gen:key": "ssh-keygen -t rsa -b 2048 -f src/app/userResolver/auth/jwtRS256.key && openssl rsa -in src/app/userResolver/auth/wtRS256.key -pubout -outform PEM -out src/app/userResolver/auth/wtRS256.key.pub" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "apollo-server": "^2.9.3", + "apollo-server-express": "^2.9.4", + "argon2": "^0.24.1", + "dotenv": "^8.1.0", + "express": "^4.17.1", + "graphql": "^14.5.4", + "jsonwebtoken": "^8.5.1", + "pg": "^7.12.1", + "reflect-metadata": "^0.1.13", + "type-graphql": "^0.17.5", + "typeorm": "^0.2.18" + }, + "devDependencies": { + "@types/express": "^4.17.1", + "@types/graphql": "^14.5.0", + "@types/jest": "^24.0.18", + "@types/js-cookie": "^2.2.2", + "@types/jsonwebtoken": "^8.3.3", + "@types/node": "^12.7.5", + "class-transformer": "^0.2.3", + "jest": "^24.9.0", + "ts-jest": "^24.1.0", + "ts-node-dev": "^1.0.0-pre.42", + "typescript": "^3.6.3" + } } diff --git a/src/User/UserResolver.ts b/src/User/UserResolver.ts deleted file mode 100644 index d64c729..0000000 --- a/src/User/UserResolver.ts +++ /dev/null @@ -1,62 +0,0 @@ -import "reflect-metadata" -import { Arg, Authorized, Ctx, Field, Mutation, ObjectType, Query, Resolver } from "type-graphql" -import { comparePassword, MyContext, signToken } from "../auth" -import { User } from "../User" - -@ObjectType() -class LoginTokens { - @Field() - accessToken: string = "" -} - -@Resolver(() => User) -export class UserResolver { - @Query(() => [User]) - async users() { - return await User.find() - } - - @Query(() => LoginTokens) - async loginTokens( - @Arg("email") email: string, - @Arg("password") password: string - ): Promise { - const user = await User.findOne({ where: { email } }) - - if (!user) { - throw new Error("could not find user") - } - - const passwordValid = await comparePassword(user.password, password) - - if (!passwordValid) { - throw new Error("password not valid") - } - - const accessToken = signToken({ userId: user.id }) - - return { - accessToken, - } - } - - @Query(() => User) - @Authorized() - async me(@Ctx() { payload }: MyContext) { - const id = payload!.userId - const user = await User.findOne({ where: { id } }) - - return user - } - - @Mutation(() => User) - async createUser( - @Arg("email") email: string, - @Arg("password") password: string - ): Promise { - return await User.create({ - email, - password, - }).save() - } -} diff --git a/src/app.ts b/src/app.ts index ae10de3..f508f60 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,37 +1,26 @@ require("dotenv").config() import { ApolloServer } from "apollo-server-express" import { createConnection } from "typeorm" -import { createSchema } from "./schema" -import { User } from "./User" +import { connectionOptions, createSchema } from "./app/schema" import express = require("express") - ;(async () => { - await createConnection({ - type: "postgres", - host: "localhost", - port: 5432, - database: "postgres", - username: "postgres", - password: "postgres", - // dropSchema: true, - entities: [User], - synchronize: true, - logging: false, - }) + await createConnection(connectionOptions) - const server = new ApolloServer({ - schema: await createSchema(), - playground: true, - introspection: true, - debug: true, - context: ({ req, res }) => ({ req, res }), - }) + const server = new ApolloServer({ + schema: await createSchema(), + playground: true, + introspection: true, + debug: true, + context: ({ req, res }) => ({ req, res }), + }) - const app = express() - server.applyMiddleware({ app }) + const app = express() + server.applyMiddleware({ app }) - const PORT = process.env.PORT || 4000 - app.listen({ port: PORT }, () => - console.log(`🚀 Server ready at http://localhost:${PORT}${server.graphqlPath}. `) - ) + const APP_PORT = process.env.APP_PORT || 4000 + app.listen({ port: APP_PORT }, () => + console.log( + `🚀 Server ready at http://localhost:${APP_PORT}${server.graphqlPath}. ` + ) + ) })() diff --git a/src/User/UserResolver.spec.ts b/src/app/UserResolver.spec.ts similarity index 70% rename from src/User/UserResolver.spec.ts rename to src/app/UserResolver.spec.ts index cd1b7f8..dd8f154 100644 --- a/src/User/UserResolver.spec.ts +++ b/src/app/UserResolver.spec.ts @@ -1,22 +1,12 @@ import { gql } from "apollo-server" import { createConnection, getConnection } from "typeorm" -import { signToken, verifyToken } from "../auth" -import { callSchema } from "../schema" -import { User } from "../User" +import { callSchema, connectionOptions } from "./schema" +import { signToken, verifyToken } from "./userResolver/auth" +import { Tokens } from "./userResolver/Tokens" +import { User } from "./userResolver/User" beforeAll(async () => { - return await createConnection({ - type: "postgres", - host: "localhost", - port: 5432, - database: "testing", - username: "postgres", - password: "postgres", - // dropSchema: true, - entities: [User], - synchronize: true, - logging: false, - }) + return await createConnection(connectionOptions) }) afterAll(async () => { @@ -70,16 +60,17 @@ describe("resolver of user", () => { }) }) - describe("loginTokens query should", () => { - const loginTokensQuery = gql` + describe("tokens query should", () => { + const tokensQuery = gql` query { - loginTokens(email: "email@email.com", password: "good-password") { + tokens(email: "email@email.com", password: "good-password") { accessToken } } ` + it("return error for non-existent user", async () => { - const response = await callSchema(loginTokensQuery) + const response = await callSchema(tokensQuery) expect(response.errors).not.toBeUndefined() expect(response.data).toBeNull() @@ -91,7 +82,7 @@ describe("resolver of user", () => { password: "BAD-password", }).save() - const response = await callSchema(loginTokensQuery) + const response = await callSchema(tokensQuery) expect(response.errors).not.toBeUndefined() expect(response.data).toBeNull() @@ -103,9 +94,13 @@ describe("resolver of user", () => { password: "good-password", }).save() - const response = await callSchema(loginTokensQuery) - const token = response.data!.loginTokens.accessToken + const response = await callSchema(tokensQuery) + const token = response.data!.tokens.accessToken + const tokens = new Tokens() + tokens.accessToken = token + expect(response.errors).toBeUndefined() + expect(response.data).toMatchObject({ tokens }) expect(verifyToken(token)).toBeTruthy() }) }) @@ -120,7 +115,14 @@ describe("resolver of user", () => { ` it("return an error without a valid jwt token", async () => { - const response = await callSchema(meQuery) + const context = { + req: { + headers: { + authorization: "Bearer INVALID-TOKEN", + }, + }, + } + const response = await callSchema(meQuery, context) expect(response.errors).not.toBeUndefined() expect(response.data).toBeNull() diff --git a/src/app/UserResolver.ts b/src/app/UserResolver.ts new file mode 100644 index 0000000..ca998cc --- /dev/null +++ b/src/app/UserResolver.ts @@ -0,0 +1,54 @@ +import "reflect-metadata" +import { Arg, Authorized, Ctx, Mutation, Query } from "type-graphql" +import { comparePassword, MyContext, signToken } from "./userResolver/auth" +import { Tokens } from "./userResolver/Tokens" +import { User } from "./userResolver/User" + +export class UserResolver { + @Query(() => [User]) + async users() { + return await User.find() + } + + @Query(() => Tokens) + async tokens( + @Arg("email") email: string, + @Arg("password") password: string + ): Promise { + try { + const user = await User.findOne({ where: { email } }) + + if (!(await comparePassword(user!.password, password))) { + throw new Error() + } + + const accessToken = signToken({ userId: user!.id }) + + return { + accessToken, + } + } catch (error) { + throw new Error("login credentials are invalid") + } + } + + @Query(() => User) + @Authorized() + async me(@Ctx() { payload }: MyContext) { + const id = payload!.userId + const user = await User.findOne({ where: { id } }) + + return user + } + + @Mutation(() => User) + async createUser( + @Arg("email") email: string, + @Arg("password") password: string + ): Promise { + return await User.create({ + email, + password, + }).save() + } +} diff --git a/src/app/schema.ts b/src/app/schema.ts new file mode 100644 index 0000000..1af75e0 --- /dev/null +++ b/src/app/schema.ts @@ -0,0 +1,39 @@ +require("dotenv").config() +import { DocumentNode, graphql, GraphQLSchema } from "graphql" +import { buildSchema } from "type-graphql" +import { ConnectionOptions } from "typeorm" +import { UserResolver } from "./UserResolver" +import { customAuthChecker } from "./userResolver/auth" +import { User } from "./userResolver/User" + +let schema: GraphQLSchema + +export const callSchema = async (document: DocumentNode, context?: any) => { + if (!schema) { + schema = await createSchema() + } + + return graphql({ + schema, + source: document.loc!.source.body, + contextValue: context, + }) +} + +export const createSchema = () => + buildSchema({ + resolvers: [UserResolver], + authChecker: customAuthChecker, + }) + +export const connectionOptions: ConnectionOptions = { + type: "postgres", + host: process.env.DB_HOST, + port: 5432, + database: process.env.DB_NAME, + username: process.env.DB_USER, + password: process.env.DB_PASS, + entities: [User], + synchronize: true, + logging: false, +} diff --git a/src/app/userResolver/Tokens.ts b/src/app/userResolver/Tokens.ts new file mode 100644 index 0000000..b8c7e3a --- /dev/null +++ b/src/app/userResolver/Tokens.ts @@ -0,0 +1,8 @@ +import "reflect-metadata" +import { Field, ObjectType } from "type-graphql" + +@ObjectType() +export class Tokens { + @Field() + accessToken: string = "" +} diff --git a/src/User.ts b/src/app/userResolver/User.ts similarity index 50% rename from src/User.ts rename to src/app/userResolver/User.ts index d7821b5..acf7697 100644 --- a/src/User.ts +++ b/src/app/userResolver/User.ts @@ -6,18 +6,18 @@ import { hashPassword } from "./auth" @ObjectType() @Entity() export class User extends BaseEntity { - @PrimaryGeneratedColumn() - id!: number + @PrimaryGeneratedColumn() + id!: number - @Field() - @Column() - email: string = "" + @Field() + @Column() + email: string = "" - @Column() - password: string = "" + @Column() + password: string = "" - @BeforeInsert() - async hashPassword() { - this.password = await hashPassword(this.password) - } + @BeforeInsert() + async hashPassword() { + this.password = await hashPassword(this.password) + } } diff --git a/src/app/userResolver/auth.ts b/src/app/userResolver/auth.ts new file mode 100644 index 0000000..90856b1 --- /dev/null +++ b/src/app/userResolver/auth.ts @@ -0,0 +1,56 @@ +import { argon2id, hash, verify as argonVerify } from "argon2" +import { Request, Response } from "express" +import { readFileSync } from "fs" +import { sign, verify as jwtVerify } from "jsonwebtoken" +import { join } from "path" +import { AuthChecker } from "type-graphql" + +let PRIVATE_KEY: Buffer +let PUBLIC_KEY: Buffer + +export type Payload = { + userId: number +} + +export interface MyContext { + req: Request + res: Response + payload?: Payload +} + +export const hashPassword = async (password: string) => + await hash(password, { type: argon2id }) + +export const comparePassword = async (hash: string, plain: string) => + await argonVerify(hash, plain, { type: argon2id }) + +export const signToken = (payload: Payload) => { + if (!PRIVATE_KEY) { + PRIVATE_KEY = readKeyFile("jwtRS256.key") + } + + return sign(payload, PRIVATE_KEY, { algorithm: "RS256" }) +} + +export const verifyToken = (token: string) => { + if (!PUBLIC_KEY) { + PUBLIC_KEY = readKeyFile("jwtRS256.key.pub") + } + + return jwtVerify(token, PUBLIC_KEY) +} + +export const customAuthChecker: AuthChecker = ({ context }) => { + try { + const authHeader = context.req.headers["authorization"] + const token = authHeader!.split(" ")[1] + const payload = verifyToken(token) + context.payload = payload as any + + return true + } catch (error) { + throw new Error("the valid authorization header is required") + } +} + +const readKeyFile = (fileName: string) => readFileSync(join(__dirname, "auth", fileName)) diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index c4bcf11..0000000 --- a/src/auth.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { argon2id, hash, verify as argonVerify } from "argon2" -import { Request, Response } from "express" -import { readFileSync } from "fs" -import { sign, verify as jwtVerify } from "jsonwebtoken" -import { join } from "path" -import { AuthChecker } from "type-graphql" - -export type Payload = { - userId: number -} - -export interface MyContext { - req: Request - res: Response - payload?: Payload -} - -export async function hashPassword(password: string) { - return await hash(password, { type: argon2id }) -} - -export async function comparePassword(hash: string, plain: string) { - return await argonVerify(hash, plain, { type: argon2id }) -} - -export function signToken(payload: Payload) { - const PRIVATE_KEY = readFileSync(join(__dirname, "auth", "jwtRS256.key")) - - return sign(payload, PRIVATE_KEY, { algorithm: "RS256" }) -} - -export function verifyToken(token: string) { - const PUBLIC_KEY = readFileSync(join(__dirname, "auth", "jwtRS256.key.pub")) - - return jwtVerify(token, PUBLIC_KEY) -} - -export const customAuthChecker: AuthChecker = ({ context }) => { - const authHeader = context.req.headers["authorization"] - - if (!authHeader) { - throw new Error("authorization header is missing") - } - - const token = authHeader.split(" ")[1] - - if (!token) { - throw new Error("token not present in authorization header") - } - - const payload = verifyToken(token) - - if (!payload) { - throw new Error("payload not present in the token") - } - - context.payload = payload as any - return true -} diff --git a/src/schema.ts b/src/schema.ts deleted file mode 100644 index 0cba085..0000000 --- a/src/schema.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { DocumentNode, graphql, GraphQLSchema } from "graphql" -import { buildSchema } from "type-graphql" -import { customAuthChecker } from "./auth" -import { UserResolver } from "./User/UserResolver" - -let schema: GraphQLSchema - -export const callSchema = async (document: DocumentNode, context?: any) => { - if (!schema) { - schema = await createSchema() - } - - return graphql({ - schema, - source: document.loc!.source.body || "", - contextValue: context, - }) -} - -export const createSchema = () => - buildSchema({ - resolvers: [UserResolver], - authChecker: customAuthChecker, - })