From 7e4d01de7917e28ea106bdf64ad357f6a1aef441 Mon Sep 17 00:00:00 2001 From: scffs Date: Sun, 10 Dec 2023 01:50:27 +0700 Subject: [PATCH] add base auth --- apps/api/.env.example | 1 + apps/api/__tests__/services/user.test.ts | 1 - apps/api/package.json | 2 ++ apps/api/prisma/schema.prisma | 8 +++-- apps/api/src/handlers/authHandler.ts | 42 +++++++++++++++++++++++ apps/api/src/handlers/index.ts | 1 + apps/api/src/handlers/userHandler.ts | 4 +-- apps/api/src/index.ts | 16 ++++++++- apps/api/src/middlewares/auth.ts | 0 apps/api/src/routes/auth/authRoutes.ts | 19 ++++++++++ apps/api/src/routes/auth/authSchema.ts | 7 ++++ apps/api/src/services/authService.ts | 39 +++++++++++++++++++++ apps/api/src/services/index.ts | 1 + apps/api/src/types/index.ts | 6 ++-- apps/api/src/utils/comparePassword.ts | 17 +++++++++ apps/api/src/utils/getRandomNumber.ts | 4 +-- apps/api/src/utils/hashPassword.ts | 15 ++++++++ apps/api/src/utils/index.ts | 4 +++ apps/api/src/utils/md5hash.ts | 11 ++++++ apps/api/src/utils/pbkdf2Hash.ts | 22 ++++++++++++ apps/shared/src/types/index.ts | 4 ++- bun.lockb | Bin 10496 -> 13816 bytes package.json | 6 ++-- 23 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 apps/api/src/handlers/authHandler.ts create mode 100644 apps/api/src/middlewares/auth.ts create mode 100644 apps/api/src/routes/auth/authRoutes.ts create mode 100644 apps/api/src/routes/auth/authSchema.ts create mode 100644 apps/api/src/services/authService.ts create mode 100644 apps/api/src/utils/comparePassword.ts create mode 100644 apps/api/src/utils/hashPassword.ts create mode 100644 apps/api/src/utils/md5hash.ts create mode 100644 apps/api/src/utils/pbkdf2Hash.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index e60a1b7..017cdb7 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,5 +1,6 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=12345678 POSTGRES_DB=diary-db +JWT_SECRET=itssecret DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?schema=public diff --git a/apps/api/__tests__/services/user.test.ts b/apps/api/__tests__/services/user.test.ts index bcd92ab..b6ca7bd 100644 --- a/apps/api/__tests__/services/user.test.ts +++ b/apps/api/__tests__/services/user.test.ts @@ -44,5 +44,4 @@ describe('userService', () => { expect(user).not.toBeNull() expect(user?.name).toBe('Test User') }) - }) diff --git a/apps/api/package.json b/apps/api/package.json index be377f0..4498f8e 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,8 @@ "tests": "bun test --coverage" }, "dependencies": { + "@elysiajs/cookie": "^0.7.0", + "@elysiajs/jwt": "^0.7.0", "@elysiajs/swagger": "^0.7.4", "@prisma/client": "5.7.0", "elysia": "latest", diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index e11e542..0b4a704 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -11,7 +11,9 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - email String @unique - name String? + id Int @id @default(autoincrement()) + email String @unique + password String + salt String + name String? } diff --git a/apps/api/src/handlers/authHandler.ts b/apps/api/src/handlers/authHandler.ts new file mode 100644 index 0000000..21c5c65 --- /dev/null +++ b/apps/api/src/handlers/authHandler.ts @@ -0,0 +1,42 @@ +import { ApiResponse, ContextWith } from '../types' +import { authenticateUser } from '@services' + +interface Body { + password: string + login: string + id: number +} + +export const postAuth = async ({ + body, + jwt, + setCookie, + cookie +}: ContextWith): Promise> => { + if (typeof body === 'undefined') { + return { + success: false, + data: 'Body is required' + } + } + + const { login, password, id } = body + + const { success } = await authenticateUser(id, login, password) + + setCookie('auth', await jwt.sign(body), { + httpOnly: true, + maxAge: 7 * 86400, + secure: true + }) + + const verify = await jwt.verify(cookie.auth) + + console.debug('verify', verify) + console.debug('cookie.auth', cookie.auth) + console.debug('success', success) + + return { + success, + } +} diff --git a/apps/api/src/handlers/index.ts b/apps/api/src/handlers/index.ts index 87d7aef..2013135 100644 --- a/apps/api/src/handlers/index.ts +++ b/apps/api/src/handlers/index.ts @@ -1 +1,2 @@ export * from './userHandler' +export * from './authHandler' diff --git a/apps/api/src/handlers/userHandler.ts b/apps/api/src/handlers/userHandler.ts index 9129193..6d81a87 100644 --- a/apps/api/src/handlers/userHandler.ts +++ b/apps/api/src/handlers/userHandler.ts @@ -1,5 +1,5 @@ +import { getAllUsers, getUserById } from '@services' import { User } from 'shared' -import { getAllUsers, getUserById } from '../services/userService' import { ApiResponse, ContextWith } from '../types' type Params = { id: string } @@ -15,7 +15,7 @@ export const getAllUsersHandler = async (): Promise> => { export const getUserByIdHandler = async ({ params -}: ContextWith): Promise> => { +}: ContextWith): Promise> => { const userId = parseInt(params.id, 10) const user = await getUserById(userId) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3779b16..ec679a1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,8 +1,22 @@ +import cookie from '@elysiajs/cookie' +import jwt, { JWTOption } from '@elysiajs/jwt' import { handleErrors } from '@utils' import Elysia from 'elysia' +import auth from './routes/auth/authRoutes' import users from './routes/user/userRoutes' -const app = new Elysia().use(users).listen(3000).onError(handleErrors) +const authConfig: JWTOption = { + name: 'jwt', + secret: Bun.env.JWT_SECRET ?? 'secret' +} + +const app = new Elysia() + .use(jwt(authConfig)) + .use(cookie()) + .use(users) + .use(auth) + .listen(3000) + .onError(handleErrors) console.log( `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}` diff --git a/apps/api/src/middlewares/auth.ts b/apps/api/src/middlewares/auth.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/src/routes/auth/authRoutes.ts b/apps/api/src/routes/auth/authRoutes.ts new file mode 100644 index 0000000..a425883 --- /dev/null +++ b/apps/api/src/routes/auth/authRoutes.ts @@ -0,0 +1,19 @@ +import { postAuth } from '@handlers' +import Elysia, { t } from 'elysia' +import { authBody } from './authSchema' +import { getUserById } from '@services' + +const app = new Elysia().post('/auth', postAuth, { + body: t.Object(authBody), + // TODO: move to custom plugin (middleware) + beforeHandle: async (context) => { + const user = await getUserById(context.body.id) + + if (!user || user.id !== context.body.id || user.login !== context.body.login) { + context.set.status = 400 + return 'Bad request' + } + } +}) + +export default app diff --git a/apps/api/src/routes/auth/authSchema.ts b/apps/api/src/routes/auth/authSchema.ts new file mode 100644 index 0000000..794e011 --- /dev/null +++ b/apps/api/src/routes/auth/authSchema.ts @@ -0,0 +1,7 @@ +import { t } from 'elysia' + +export const authBody = { + id: t.Number(), + password: t.String(), + login: t.String() +} diff --git a/apps/api/src/services/authService.ts b/apps/api/src/services/authService.ts new file mode 100644 index 0000000..fdf43e4 --- /dev/null +++ b/apps/api/src/services/authService.ts @@ -0,0 +1,39 @@ +import { comparePassword } from '@utils' +import { ApiResponse } from '../types' +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export const authenticateUser = async ( + id: number, + login: string, + password: string, +): Promise> => { + console.log(id) + console.log(password) + console.log(login) + const user = await prisma.user.findUnique({ + where: { + email: login, + id + }, + }) + + const isValidPassword = await comparePassword( + password, + user!.salt, + user!.password, + ) + + if (!isValidPassword) { + return { + success: false, + data: 'Invalid password', + } + } + + return { + success: true, + data: 'Password', + } +} diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index c812d82..fc7ab8f 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1 +1,2 @@ export * from './userService' +export * from './authService' diff --git a/apps/api/src/types/index.ts b/apps/api/src/types/index.ts index 44625b9..8661a4d 100644 --- a/apps/api/src/types/index.ts +++ b/apps/api/src/types/index.ts @@ -3,10 +3,12 @@ import { Context as ElysiaContext } from 'elysia' /** Стандартизирует ответ API: флаг `success` и данные с кастомным типом T */ export interface ApiResponse { success: boolean - data: T + data?: T } /** Расширяет контекст Elysia с сильно-типизированным объектом `params` */ -export interface ContextWith extends Omit { +export interface ContextWith + extends Omit { params: T + body?: U } diff --git a/apps/api/src/utils/comparePassword.ts b/apps/api/src/utils/comparePassword.ts new file mode 100644 index 0000000..e6092f6 --- /dev/null +++ b/apps/api/src/utils/comparePassword.ts @@ -0,0 +1,17 @@ +import { pbkdf2Hash } from './pbkdf2Hash' + +/** + * Сравнивает пароль с хешем и солью для проверки соответствия. + * @param password - Пароль, который нужно сравнить. + * @param salt - Соль, использованная при хешировании. + * @param hash - Хеш, с которым сравнивается пароль. + * @returns Промис, который разрешается булевым значением, указывающим соответствие пароля хешу. + */ +export const comparePassword = async ( + password: string, + salt: string, + hash: string +): Promise => { + const derivedKey = await pbkdf2Hash(password, salt) + return hash === derivedKey +} diff --git a/apps/api/src/utils/getRandomNumber.ts b/apps/api/src/utils/getRandomNumber.ts index 00ffc18..332b573 100644 --- a/apps/api/src/utils/getRandomNumber.ts +++ b/apps/api/src/utils/getRandomNumber.ts @@ -1,7 +1,7 @@ export const getRandomID = () => { - let array = new Uint32Array(1) + const array = new Uint32Array(1) crypto.getRandomValues(array) - + const maxIntValue = 2147483647 return array[0] % maxIntValue } diff --git a/apps/api/src/utils/hashPassword.ts b/apps/api/src/utils/hashPassword.ts new file mode 100644 index 0000000..252ba85 --- /dev/null +++ b/apps/api/src/utils/hashPassword.ts @@ -0,0 +1,15 @@ +import { randomBytes } from 'crypto' +import { pbkdf2Hash } from './pbkdf2Hash' + +/** + * Хеширует пароль с использованием PBKDF2 и случайно сгенерированной соли. + * @param password - Пароль, который нужно захешировать. + * @returns Промис, который разрешается объектом с хешем и солью. + */ +export const hashPassword = async ( + password: string +): Promise<{ hash: string; salt: string }> => { + const salt = randomBytes(16).toString('hex') + const hash = await pbkdf2Hash(password, salt) + return { hash, salt } +} diff --git a/apps/api/src/utils/index.ts b/apps/api/src/utils/index.ts index 1de1acb..c1d4735 100644 --- a/apps/api/src/utils/index.ts +++ b/apps/api/src/utils/index.ts @@ -1,2 +1,6 @@ export * from './handleErrors' export * from './getRandomNumber' +export * from './pbkdf2Hash' +export * from './md5hash' +export * from './hashPassword' +export * from './comparePassword' diff --git a/apps/api/src/utils/md5hash.ts b/apps/api/src/utils/md5hash.ts new file mode 100644 index 0000000..5b55551 --- /dev/null +++ b/apps/api/src/utils/md5hash.ts @@ -0,0 +1,11 @@ +import { createHash } from 'crypto' + +/** + * Генерирует MD5-хеш для заданного текста. + * @param text - Текст, который нужно захешировать. + * @returns MD5-хеш введенного текста. + */ +function md5hash(text: string) { + // Создаем объект хеша MD5, обновляем его текстом и получаем результат в шестнадцатеричном формате. + return createHash('md5').update(text).digest('hex') +} diff --git a/apps/api/src/utils/pbkdf2Hash.ts b/apps/api/src/utils/pbkdf2Hash.ts new file mode 100644 index 0000000..cf6e568 --- /dev/null +++ b/apps/api/src/utils/pbkdf2Hash.ts @@ -0,0 +1,22 @@ +import { pbkdf2 } from 'crypto' + +/** + * Хеширует данные с использованием PBKDF2. + * @param password - Данные для хеширования (например, пароль). + * @param salt - Соль для хеширования. + * @returns Промис, который разрешается строкой с хешем. + */ +export const pbkdf2Hash = async ( + password: string, + salt: string +): Promise => { + return new Promise((resolve, reject) => { + pbkdf2(password, salt, 1000, 64, 'sha512', (error, derivedKey) => { + if (error) { + return reject(error) + } + + return resolve(derivedKey.toString('hex')) + }) + }) +} diff --git a/apps/shared/src/types/index.ts b/apps/shared/src/types/index.ts index f383d14..13d9d61 100644 --- a/apps/shared/src/types/index.ts +++ b/apps/shared/src/types/index.ts @@ -1,5 +1,7 @@ export interface User { id: number email: string - name?: string | null + password: string + salt: string + name?: string } diff --git a/bun.lockb b/bun.lockb index f3e129b792ec57de45b59a8a050130b8e9aab420..75712f9138632fd86a6c1827c892714fcbded2c3 100644 GIT binary patch delta 3542 zcmcgvc~p~E7Jpw7LI|=1L9#$dSOSQN2_XVvgG=2GDj+QiRTKfkVnq#9#n#}IcC6w8 zZ&VhQDz#dBidKrdt?f+Jx)qm>Qe1GGq72nKZpXUJyNWFaBjy~?e3uii9%U_7W>5T2Q=FCZRN#6yQ3^XG&mX%1u|Xj3L1yw!eT49 zC8Qq=3~Y|bj$A+)z@uIgMDGr`3a}60I9Nx=*8J>wdAaPI{R6M`m3?@ikWOHBjp>^9 z?uFCBM@+$Aj|^!{{v=w{&_4b8gtUwG2c!0w7k)mNp8M#gA+6g*dH;uxE5u(fts0oQ zLeug}yj$^*BX?5k4s@9hH-s=PG$T=rjcgA?`2vG zzh>ITPjst+%npaZTRRIAkn1`QW--p3ldx|!_3UiY9M`M z*ja~j7U4q3U|f@}fLR%ugd9QooHT>Z6^8R%g|tp+W^A-gn8+NZGS@_&yDQCfH8U1k z>l#Z_cp|qgpcV%f2)!nVm0SkS035i&LnQ(#;f1quo{&{DC+ATP+!)~C4iR3pEQ%F5 z28%Iv5n-&$GvJcx27ZDh9S$ZQ*5P*G&FeAhQ4c%AUM9aV1P+fHqSB*Fp1+vNMT2difJe*{BBlba3`>867&crzmi`l>oDU4dzA!K?aODV` z4w1vabp;_d0CwU;3|qx=zeQ9~JJu25MvxE(kBE8^4!$>{UL*{}{*L_~wEK5%;Qs$L zBk2EkDPd!T)Hv~fnGsn}D(nuOd~=%{Uo=e65#F!W?RM_8u9waCg}y%L+lsF5_(s|~ z>y}Xa_5xnEFvs#VwYzLt==-zI95_`h2`qmsS^3r25rOMJQ?6`RYj4m%AF1t)JN=`U zyx37MO7{O{OPE=IwCH)oTKN;1fgIcUBhx2)*LB?y!GrFD6F0xv=kMn}d7M^qM;TWv zs1N=uVwU&DjSC?(hP?240cQyH)ky-W>V`NoGyYF!3p?Gdb5A7IlJ`Xg`*uzHa+u{| z^?)UPZf+SHcd+_|?e$@Am}ie{+UDDM==B0Zq?>2n>`Wb*+xORAhVfEzB)I#{T6+Fb z=S6E)`||d!qVVtU#LV$H*3dR%U&WLu=j-}SUAemWNqqWWIy^=imh4$2wMK>Q+<)Ut zv%6pXx`ST~hfmNMu=A3maY6QmBWaN@8;=D=)LPao?zk)8`)qs3MKN~$h>oWH&?r>5z?_EK7Z_uQ8w z8y0s@07G1g139aJ0`Na+dfzSBW2BmQ=tP7Rn%lGbeS@6>D#-i)sL@zmEfl6PrP+${%yGIy zVN|q(6>L(re$bt#$&MHeQYM{V2TAXw&lR#kTvrw<;kWd!ogL3(U=xiHu5a_YT7Sc^ za{7GGG(^Lmk$5t6T!54bqj>>F6W5l#)ub-X+^s*&V@wc*9t>~|S$xxo5#GGYRXj!^ zu)q2_^i+V1nM0EUWlRNS0%eN-PL|bEaD80Walb(?2h2DGfyL;+DIr;OL!c~x>-**o zSoU-96`%8teF%wR=-I$%Bi8_ir5x?l9Jv1ukKyV#*9vm|8mnrE(Z$f)feH)P@Nr!k z?CQZW{x) zq>CN|XV&U?$-N`#Q;mX-P-2Ts^OgE=DPwP0E8VwM1Q$BatTNCJ zxs+Z|s{7ib9-236p|v2p&^oYieo6Ds$`!8ka;Vq~n1Nbc(&By7(<*h4Hzp1j0kknX zgNYsv3#L~>-NxH3!IphuSJ9PN%7&IKoRepDEdPelS_>?*vr9_nShJ3DxMsaD_O<@vm9FiOM5J LF%xNaM8m%U3aL~& delta 1491 zcmbVMTWAzX6s_t^+hZru7>!QPNHod3qwOR!ZA>)Nx^aWbZeY!aagp_fk0EGcBpQ^E z5qCwzCl^G~MFl@}F%cOQe+;622np)Pegu53xE~2@*!fTbLiAKmx2TAS7jB(f_f_{+ zacitKz4EvodD+o7c+p>$eUseUfA-GRKLZ61o0gw?J@BOS$kJ1u@QwB`vq(F!YL;YZ zC%R_$WT8+QOTPjZ0v`h>0tbLaK)1x02JE-(8{67iQ=1s;#eNF(A>brndq-;Lw)Ko% z%VTUZ_FG(xO#?RD*a#H)=K*H`r|05*>6H_52Q2}Lgi)Yys8?tYe?Z5j7QUE8WvmBi80&3{xJ+I~DVI5MFRJ|k!7}Y|)$m(1>@uaSL{T}(3z!E;7=&#h;eTpJrmaYFBh1x0pWBE%B2)odWe3R|-zuJ*- z{Dw~8^&O44tuSZ_IRnWqMUA)&ukK<3#}LI1J4ADyAmg3z{Jq8d@zQ>dZ=RHR4|6d zdIxb=%S15fo|RTaS*I0HXT@%H%r=&mlGh&;?@Ok?d4`ywy62nMw*2z&nZ$pK={0(S zu@F7=|IB--G7#tev^Jp4cLx05p-AiVMJK1iw@R(SHOYb73o0ZG^^ zMtuQ~FAGup8Rr7&IhB8G+AxhL>t`E!Opm$gpFrGj{(xP5Wu3p?h&_@WfRBW}>6sqR+v@fKoTkQe3R7yv~i|J7~=6#E*3sB$h3yeYD zHEDdvb~zIyZ=^BP9oer?M?9tu1>p$#KS)>N74#_XQ5S@As{0{i^HE_UqmJ3CqA9=5#Z8kO9j)yBE^J(&O1 diff --git a/package.json b/package.json index 258ace4..845d36e 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,14 @@ "test": "echo \"Error: no test specified\" && exit 1", "prepare": "husky install && bun format", "dev:api": "cd apps/api && bun dev", - "dev:startDB": "cd apps/api && db:start", + "dev:web": "cd apps/web && bun dev", + "dev:startDB": "cd apps/api && bun db:start", "lint": "biome lint . --apply", "format": "biome format . --write", "checkAll": "biome check . --apply", "build:web": "cd apps/web && bun run build", "build:api": "cd apps/api && bun run build", - "build:api:types": "cd apps/api && bun run build:types", - "build:shared": "cd apps/shared && bun run build" + "build:api:types": "cd apps/api && bun run build:types" }, "workspaces": [ "apps/*"