Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Commit

Permalink
feat: add auth (#33)
Browse files Browse the repository at this point in the history
* add base auth

* lint

* upd

* return null back

* fix: случайно удалил

* fixes

* fixes

* lint

* improve auth

* add more tests & config

* feat: add middleware

* feat: add middleware

* chore: fix type error

* chore: fix type error

* feat: upgrade logic

* feat: update tests

* chore: lint

* chore: lint

* chore: add @description
  • Loading branch information
scffs authored Dec 12, 2023
1 parent 4d79c9f commit b29dcb9
Show file tree
Hide file tree
Showing 35 changed files with 420 additions and 40 deletions.
1 change: 1 addition & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion apps/api/__tests__/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Тесты

#### Тестируем сервисы (бизнес логику)
- Для тестов использовать ЧИСТУЮ ТЕСТОВУЮ БД
- Запускать БД как обычно в докере
- После запуска тестов надо удалить БД, иначе след. запуск тестов может упасть из-за повторяющихся данных
Expand Down
51 changes: 51 additions & 0 deletions apps/api/__tests__/services/auth/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterAll, beforeEach, describe, expect, it } from 'bun:test'
import { PrismaClient } from '@prisma/client'
import { authenticateUser, isUserExist } from '@services'
import { getTestUser } from '@utils'

const testDb = new PrismaClient()

describe('authenticationService', () => {
afterAll(async () => {
await testDb.$disconnect()
})

beforeEach(async () => {
await testDb.user.deleteMany()
})

it('should fail authentication for a non-existent user', async () => {
const user = await isUserExist(1, 'nonexistent@example.com')

expect(user).toBe(null)
})

it('should fail authentication for an invalid password', async () => {
const id = 1
const testUser = await getTestUser(id)
await testDb.user.create({
data: testUser
})

const result = await authenticateUser(
testUser,
testUser.email,
'wrongpassword'
)
expect(result).toBe(false)
})

it('should authenticate a user with valid credentials', async () => {
const id = 1
const testUser = await getTestUser(id)
await testDb.user.create({
data: testUser
})

/**
* В getTestUser пароль хешируется, но по задумке authenticateUser мы передаем туда пароль в чистом виде.
*/
const result = await authenticateUser(testUser, testUser.email, 'password')
expect(result).toBe(true)
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterAll, describe, expect, it } from 'bun:test'
import { afterAll, beforeEach, describe, expect, it } from 'bun:test'
import { PrismaClient } from '@prisma/client'
import { getAllUsers, getUserById } from '@services'
import { getRandomID } from '@utils'
import { getRandomID, getTestUser } from '@utils'

const testDb = new PrismaClient()

Expand All @@ -10,39 +10,35 @@ describe('userService', () => {
await testDb.$disconnect()
})

beforeEach(async () => {
await testDb.user.deleteMany()
})

it('getAllUsers should return an array of users', async () => {
const firstID = getRandomID()
const secondID = getRandomID()

await testDb.user.createMany({
data: [
{
id: getRandomID(),
name: 'User 1',
email: 'testuser1@example.com'
},
{
id: getRandomID(),
name: 'User 2',
email: 'testuser2@example.com'
}
],
data: [await getTestUser(firstID), await getTestUser(secondID)],
skipDuplicates: true
})

const users = await getAllUsers()
expect(users).toHaveLength(2)
expect(users[0].name).toBe('User 1')
expect(users[1].name).toBe('User 2')
expect(users[0].name).toBe(`Test User ${firstID}`)
expect(users[1].name).toBe(`Test User ${secondID}`)
})

it('getUserById should return a user by ID', async () => {
const id = getRandomID()
const data = await getTestUser(id)

await testDb.user.create({
data: { id: id, name: 'Test User', email: 'testuser3@example.com' }
data
})

const user = await getUserById(id)
expect(user).not.toBeNull()
expect(user?.name).toBe('Test User')
expect(user?.name).toBe(`Test User ${id}`)
})

})
45 changes: 45 additions & 0 deletions apps/api/__tests__/utils/handleErrors/i.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// @ts-nocheck В тестах нам не важны лишние параметры для handleErrors
import { describe, expect, it } from 'bun:test'
import { handleErrors } from '@utils'

describe('handleErrors', () => {
it('should handle VALIDATION error', () => {
const result = handleErrors({
code: 'VALIDATION',
error: new Error('Validation failed')
})

expect(result.message).toBe('Validation error')
expect(result.error).toBeInstanceOf(Error)
})

it('should handle INTERNAL_SERVER_ERROR', () => {
const result = handleErrors({
code: 'INTERNAL_SERVER_ERROR',
error: new Error('Server error')
})

expect(result.message).toBe('Internal Server Error')
expect(result.error).toBeInstanceOf(Error)
})

it('should handle NOT_FOUND error', () => {
const result = handleErrors({
code: 'NOT_FOUND',
error: new Error('Resource not found')
})

expect(result.message).toBe('Not found')
expect(result.error).toBeInstanceOf(Error)
})

it('should handle unknown error', () => {
const result = handleErrors({
code: 'UNKNOWN_ERROR',
error: new Error('Unknown error occurred')
})

expect(result.message).toBe('Unknown error')
expect(result.error).toBeInstanceOf(Error)
})
})
11 changes: 11 additions & 0 deletions apps/api/__tests__/utils/hashPassword/i.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'bun:test'
import { hashPassword, pbkdf2Hash } from '@utils'

describe('hashPassword', () => {
it('should hash password with random salt', async () => {
const result = await hashPassword('password')
const expectedHash = await pbkdf2Hash('password', result.salt)

expect(result.hash).toMatch(expectedHash)
})
})
9 changes: 9 additions & 0 deletions apps/api/biome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"formatter": {
"ignore": ["./dist"]
},
"linter": {
"ignore": ["./dist"]
},
"extends": ["../../biome.json"]
}
2 changes: 2 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions apps/api/prisma/migrations/20231209175553_init/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
Warnings:
- Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty.
- Added the required column `salt` to the `User` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "User" ADD COLUMN "password" TEXT NOT NULL,
ADD COLUMN "salt" TEXT NOT NULL;
8 changes: 5 additions & 3 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
1 change: 1 addition & 0 deletions apps/api/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const POSTGRES_PASSWORD = Bun.env.POSTGRES_PASSWORD
55 changes: 55 additions & 0 deletions apps/api/src/handlers/auth/authHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { authenticateUser, isUserExist } from '@services'
import { ApiResponse, ContextWith } from '@types'

interface Body {
password: string
login: string
id: number
}

export const postAuth = async ({
body,
jwt,
set
}: ContextWith<never, Body>): Promise<ApiResponse<string>> => {
const { login, password, id } = body

const user = await isUserExist(id, login)

if (!user) {
set.status = 'Bad Request'

return {
success: false,
data: 'Bad Request'
}
}

const isAuth = await authenticateUser(user, login, password)

if (!isAuth || !jwt) {
set.status = 'Bad Request'

return {
success: false
}
}

/**
* @description jwt.sign() будет возвращать одну и ту же куку в течении даты ее жизни
*
* Если хочется генерировать новую на каждую авторизацию (например), то можно использовать такой код
*
* setCookie('auth', await jwt.sign(body), {
* httpOnly: true,
* maxAge: 7 * 86400,
* })
*
* @see https://elysiajs.com/plugins/jwt.html#jwt-plugin
*/
await jwt.sign(body)

return {
success: true
}
}
3 changes: 2 additions & 1 deletion apps/api/src/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './userHandler'
export * from './user/userHandler'
export * from './auth/authHandler'
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { getAllUsers, getUserById } from '@services'
import { ApiResponse, ContextWith } from '@types'
import { User } from 'shared'
import { getAllUsers, getUserById } from '../services/userService'
import { ApiResponse, ContextWith } from '../types'

type Params = { id: string }

Expand All @@ -15,7 +15,7 @@ export const getAllUsersHandler = async (): Promise<ApiResponse<User[]>> => {

export const getUserByIdHandler = async ({
params
}: ContextWith<Params>): Promise<ApiResponse<User | null>> => {
}: ContextWith<Params, unknown>): Promise<ApiResponse<User | null>> => {
const userId = parseInt(params.id, 10)
const user = await getUserById(userId)

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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 app = new Elysia().use(users).use(auth).listen(3000).onError(handleErrors)

console.log(
`🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`
Expand Down
27 changes: 27 additions & 0 deletions apps/api/src/middlewares/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ContextWith } from '@types'

/**
* @description Посредник для проверки авторизирован ли юзер
*
* Есть два метода:
* 1. Сделать кастомный плагин на основе интанса от Элизии.
* @see https://elysiajs.com/concept/plugin.html#plugin
*
* 2. Сделать функцию и добавить ее в 'onBeforeHandle' для нужной группы роутов
*
* P.S. Пока используем второй способ.
*/

export const auth = async ({ set, jwt, cookie }: ContextWith) => {
if (!jwt || !cookie.auth) {
set.status = 'Internal Server Error'
return
}

const isCookieValid = await jwt.verify(cookie.auth)

if (!isCookieValid) {
set.status = 'Unauthorized'
return
}
}
1 change: 1 addition & 0 deletions apps/api/src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './auth'
20 changes: 20 additions & 0 deletions apps/api/src/routes/auth/authRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import cookie from '@elysiajs/cookie'
import jwt, { JWTOption } from '@elysiajs/jwt'
import { postAuth } from '@handlers'
import Elysia, { t } from 'elysia'
import { authBody } from './authSchema'

const authConfig: JWTOption<string> = {
name: 'jwt',
secret: Bun.env.JWT_SECRET ?? 'secret',
exp: '7d'
}

const app = new Elysia()
.use(cookie())
.use(jwt(authConfig))
.post('/auth', postAuth, {
body: t.Object(authBody)
})

export default app
7 changes: 7 additions & 0 deletions apps/api/src/routes/auth/authSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { t } from 'elysia'

export const authBody = {
id: t.Number(),
password: t.String(),
login: t.String()
}
2 changes: 2 additions & 0 deletions apps/api/src/routes/user/userRoutes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { getAllUsersHandler, getUserByIdHandler } from '@handlers'
import { auth } from '@middlewares'
import Elysia, { t } from 'elysia'
import { userByIdParams } from './userSchema'

const app = new Elysia()
.onBeforeHandle(auth)
.get('/user', getAllUsersHandler)
.get('/user/:id', getUserByIdHandler, {
params: t.Object(userByIdParams)
Expand Down
Loading

0 comments on commit b29dcb9

Please sign in to comment.