Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
CLOUDINARY_CLOUD_NAME=
DATABASE_URL=
FRONTEND_URL=
REDIS_USERNAME=
REDIS_PASSWORD=
REDIS_HOST=
Expand All @@ -11,7 +12,12 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=
PORT=
API_PREFIX=
NODE_ENV=
ALLOWED_ORIGINS=
COOKIE_HTTP_ONLY=
COOKIE_SECURE=
COOKIE_SAME_SITE=
COOKIE_DOMAIN=
ACCESS_JWT_SECRET=
REFRESH_JWT_SECRET=
ACCESS_JWT_EXPIRES_IN=
Expand Down
13 changes: 5 additions & 8 deletions app/app.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,19 @@
import express from 'express'
import cookieParser from 'cookie-parser'
import cors from 'cors'
import helmet from 'helmet'
import logger from 'morgan'

import { env } from './config'
import {
globalErrorHandler,
globalLimiter,
notFoundHandler
} from './middlewares'
import { globalErrorHandler, notFoundHandler } from './middlewares'
import { apiRouter } from './routes'

export const app = express()

app.use(helmet())
app.use(cors({ origin: env.ALLOWED_ORIGINS }))
app.use(globalLimiter)
app.use(logger(app.get('env') === 'development' ? 'dev' : 'combined'))
app.use(cors({ origin: env.ALLOWED_ORIGINS, credentials: true }))
app.use(logger(env.NODE_ENV === 'development' ? 'dev' : 'combined'))
app.use(cookieParser())
app.use(express.json())

app.use(env.API_PREFIX, apiRouter)
Expand Down
9 changes: 9 additions & 0 deletions app/config/default-user-avatars.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { Theme } from '@prisma/client'

export const defaultUserAvatars: Record<Theme, string> = {
light:
'https://res.cloudinary.com/dmbnnewoy/image/upload/v1706958682/TaskPro/user_avatar_default/user_light.png',
dark: 'https://res.cloudinary.com/dmbnnewoy/image/upload/v1706958682/TaskPro/user_avatar_default/user_dark.png',
violet:
'https://res.cloudinary.com/dmbnnewoy/image/upload/v1706958682/TaskPro/user_avatar_default/user_violet.png'
}
37 changes: 19 additions & 18 deletions app/config/env.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,36 +8,37 @@ const envSchema = z.object({
CLOUDINARY_API_KEY: z.string(),
CLOUDINARY_API_SECRET: z.string(),
CLOUDINARY_CLOUD_NAME: z.string(),
DATABASE_URL: z.string(),
DATABASE_URL: z.url(),
FRONTEND_URL: z.url(),
GOOGLE_CLIENT_ID: z.string(),
GOOGLE_CLIENT_SECRET: z.string(),
GOOGLE_REDIRECT_URI: z.string().url(),
GOOGLE_REDIRECT_URI: z.url(),
REDIS_USERNAME: z.string(),
REDIS_PASSWORD: z.string(),
REDIS_HOST: z.string(),
REDIS_PORT: z.preprocess(
v => (v ? v : undefined),
z.coerce.number().int().positive()
),
REDIS_PORT: z.coerce.number().int().positive().min(1000).max(65535),
NODE_ENV: z.enum(['development', 'production']),
COOKIE_HTTP_ONLY: z.stringbool(),
COOKIE_SECURE: z.stringbool(),
COOKIE_SAME_SITE: z.enum(['lax', 'strict', 'none']),
COOKIE_DOMAIN: z.string(),
ACCESS_JWT_SECRET: z.string().transform(v => new TextEncoder().encode(v)),
REFRESH_JWT_SECRET: z.string().transform(v => new TextEncoder().encode(v)),
PORT: z.preprocess(
v => (v ? v : undefined),
z.coerce.number().int().positive()
),
PORT: z.coerce.number().int().positive().min(1000).max(65535),
API_PREFIX: z.string(),
ALLOWED_ORIGINS: z
.string()
.transform(v => v.split(','))
.pipe(z.array(z.string().url())),
.pipe(z.array(z.url())),
EMAIL_HOST: z.string(),
EMAIL_PORT: z
.number({ coerce: true })
.refine(v => availableEmailPorts.includes(v), {
message: `Email port must be one of the following: ${availableEmailPorts.join(', ')}`
}),
EMAIL_USER: z.string().email(),
EMAIL_RECEIVER: z.string().email(),
EMAIL_PORT: z.coerce
.number()
.refine(
v => availableEmailPorts.includes(v),
`Email port must be one of the following: ${availableEmailPorts.join(', ')}`
),
EMAIL_USER: z.email(),
EMAIL_RECEIVER: z.email(),
EMAIL_PASSWORD: z.string(),
ACCESS_JWT_EXPIRES_IN: z.string(),
REFRESH_JWT_EXPIRES_IN: z.string(),
Expand Down
1 change: 1 addition & 0 deletions app/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { transport } from './mailer.config'
export { default } from './cloudinary.config'
export { env } from './env.config'
export { redisClient } from './redis.config'
export { defaultUserAvatars } from './default-user-avatars.config'
111 changes: 71 additions & 40 deletions app/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import crypto from 'crypto'
import type {
GoogleCodeSchema,
RefreshTokenSchema,
SigninSchema,
SignupSchema
} from '@/schemas'
import type { JwtPayload } from '@/types'
import type { GoogleCodeSchema, SigninSchema, SignupSchema } from '@/schemas'
import type { JwtPayload, TypedRequestBody, TypedRequestQuery } from '@/types'
import type { NextFunction, Request, Response } from 'express'
import type { TypedRequestBody } from 'zod-express-middleware'

import { prisma } from '@/prisma'
import { hash, verify } from 'argon2'
Expand All @@ -16,7 +10,7 @@ import { Conflict, Forbidden, Unauthorized } from 'http-errors'
import { jwtVerify, SignJWT } from 'jose'
import { JWTExpired } from 'jose/errors'

import { env, redisClient } from '@/config'
import { defaultUserAvatars, env, redisClient } from '@/config'

const {
ACCESS_JWT_EXPIRES_IN,
Expand All @@ -27,16 +21,24 @@ const {
REFRESH_JWT_ALGORITHM,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI
GOOGLE_REDIRECT_URI,
FRONTEND_URL,
COOKIE_HTTP_ONLY,
COOKIE_DOMAIN,
COOKIE_SECURE,
COOKIE_SAME_SITE
} = env

class AuthController {
googleClient = new OAuth2Client(
private googleClient = new OAuth2Client(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI
)

private readonly ACCESS_TOKEN_NAME = 'accessToken'
private readonly REFRESH_TOKEN_NAME = 'refreshToken'

signup = async (
{ body }: TypedRequestBody<typeof SignupSchema>,
res: Response,
Expand All @@ -61,7 +63,9 @@ class AuthController {

const tokens = await this.getNewTokens({ id: user.id, sid: newSession.id })

res.json({ user, ...tokens })
this.setTokensCookie(res, tokens)

res.json({ user })
}

signin = async (
Expand Down Expand Up @@ -90,10 +94,12 @@ class AuthController {

const tokens = await this.getNewTokens({ id: user.id, sid: newSession.id })

res.json({ user: userWithoutPassword, ...tokens })
this.setTokensCookie(res, tokens)

res.json({ user: userWithoutPassword })
}

getGoogleRedirectUrl = async (_: Request, res: Response) => {
googleInitiate = async (_: Request, res: Response) => {
const state = crypto.randomBytes(32).toString('hex')

await redisClient.set(`oauth_state:${state}`, 'true', 'EX', 5 * 60)
Expand All @@ -108,44 +114,45 @@ class AuthController {
}

googleCallback = async (
req: TypedRequestBody<typeof GoogleCodeSchema>,
req: TypedRequestQuery<typeof GoogleCodeSchema>,
res: Response,
next: NextFunction
) => {
const { code, state: receivedState } = req.body
const { code, state: receivedState } = req.query

const redisStateKey = `oauth_state:${receivedState}`
if (receivedState) {
const redisStateKey = `oauth_state:${receivedState}`

const storedState = await redisClient.get(redisStateKey)
const storedState = await redisClient.get(redisStateKey)

if (storedState) {
await redisClient.del(redisStateKey)
if (storedState) {
await redisClient.del(redisStateKey)
}
}

const { tokens } = await this.googleClient.getToken(code)

if (!tokens.id_token) return next(Forbidden())

const ticket = await this.googleClient.verifyIdToken({
idToken: tokens.id_token,
audience: GOOGLE_CLIENT_ID
idToken: tokens.id_token
})

const payload = ticket.getPayload()

if (!payload || !payload.email || !payload.name || !payload.picture) {
return next(Forbidden('Invalid token'))
}
if (!payload || !payload.email) return next(Forbidden('Invalid token'))

const { name, email, picture } = payload
const {
email,
name = 'Guest',
picture = defaultUserAvatars.light
} = payload

const user = await prisma.user.findUnique({
where: { email }
})
const user = await prisma.user.findUnique({ where: { email } })

if (!user) {
const user = await prisma.user.create({
data: { name, email, avatar: picture }
data: { email, name, avatar: picture }
})

const newSession = await prisma.session.create({
Expand All @@ -157,7 +164,9 @@ class AuthController {
sid: newSession.id
})

res.json({ user, ...tokens })
this.setTokensCookie(res, tokens)

res.redirect(FRONTEND_URL)
} else {
const newSession = await prisma.session.create({
data: { userId: user.id }
Expand All @@ -168,19 +177,21 @@ class AuthController {
sid: newSession.id
})

res.json({ user, ...tokens })
this.setTokensCookie(res, tokens)

res.redirect(FRONTEND_URL)
}
}

refresh = async (
{ body }: TypedRequestBody<typeof RefreshTokenSchema>,
res: Response,
next: NextFunction
) => {
refresh = async (req: Request, res: Response, next: NextFunction) => {
const refreshToken = req.cookies.refreshToken

if (!refreshToken) return next(Forbidden())

try {
const {
payload: { id, sid }
} = await jwtVerify<JwtPayload>(body.refreshToken, REFRESH_JWT_SECRET)
} = await jwtVerify<JwtPayload>(refreshToken, REFRESH_JWT_SECRET)

const user = await prisma.user.findFirst({ where: { id } })

Expand Down Expand Up @@ -208,8 +219,9 @@ class AuthController {
}
}

logout = async ({ session }: Request, res: Response) => {
await prisma.session.delete({ where: { id: session } })
logout = async (_: Request, res: Response) => {
res.clearCookie(this.ACCESS_TOKEN_NAME)
res.clearCookie(this.REFRESH_TOKEN_NAME)

res.sendStatus(204)
}
Expand All @@ -227,6 +239,25 @@ class AuthController {

return { accessToken, refreshToken }
}

private setTokensCookie = (
res: Response,
tokens: { accessToken: string; refreshToken: string }
) => {
res.cookie(this.ACCESS_TOKEN_NAME, tokens.accessToken, {
httpOnly: COOKIE_HTTP_ONLY,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAME_SITE,
domain: COOKIE_DOMAIN
})

res.cookie(this.REFRESH_TOKEN_NAME, tokens.refreshToken, {
httpOnly: COOKIE_HTTP_ONLY,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAME_SITE,
domain: COOKIE_DOMAIN
})
}
}

export const authController = new AuthController()
6 changes: 3 additions & 3 deletions app/controllers/board.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import type {
BoardParamsSchema,
EditBoardSchema
} from '@/schemas'
import type { NextFunction, Request, Response } from 'express'
import type { ZodType } from 'zod'
import type {
TypedRequest,
TypedRequestBody,
TypedRequestParams
} from 'zod-express-middleware'
} from '@/types'
import type { NextFunction, Request, Response } from 'express'
import type { ZodType } from 'zod'

import { prisma } from '@/prisma'
import { NotFound } from 'http-errors'
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/card.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type {
EditCardSchema,
UpdateCardOrderSchema
} from '@/schemas'
import type { TypedRequest, TypedRequestParams } from '@/types'
import type { NextFunction, Response } from 'express'
import type { ZodType } from 'zod'
import type { TypedRequest, TypedRequestParams } from 'zod-express-middleware'

import { prisma } from '@/prisma'
import { BadRequest, NotFound } from 'http-errors'
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/column.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type {
EditColumnSchema,
UpdateColumnOrderSchema
} from '@/schemas'
import type { TypedRequest, TypedRequestParams } from '@/types'
import type { NextFunction, Response } from 'express'
import type { ZodType } from 'zod'
import type { TypedRequest, TypedRequestParams } from 'zod-express-middleware'

import { prisma } from '@/prisma'
import { BadRequest, NotFound } from 'http-errors'
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { EditUserSchema, NeedHelpSchema } from '@/schemas'
import type { TypedRequestBody } from '@/types'
import type { User } from '@prisma/client'
import type { NextFunction, Request, Response } from 'express'
import type { Options } from 'nodemailer/lib/mailer'
import type { TypedRequestBody } from 'zod-express-middleware'

import { prisma } from '@/prisma'
import { hash } from 'argon2'
Expand Down
Loading