Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/cucm25"
NODE_ENV="development"
JWT_SECRET="secret_jing_pa"
KEYCLOAK_API_BASE_URL="http://localhost:3000/realms/cucm25"
KEYCLOAK_CLIENT_ID="mango"
KEYCLOAK_CLIENT_SECRET="watermelon"
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"devDependencies": {
"@types/express": "^5.0.3",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^24.9.1",
"eslint": "^9.38.0",
"jest": "^30.2.0",
Expand Down
34 changes: 34 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,38 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

enum RoleType {
PARTICIPANT
MODERATOR // staff
CORETEAM // admin

@@map("role_type")
}

enum EducationLevel {
M4
M5
M6
Y1
Y2
Y3
Y4
GRADUATED
}

model User {
id String @id @map("id") @db.Uuid
studentId String @map("student_id")
username String
nickname String
firstname String
lastname String
role RoleType
educationLevel EducationLevel? @map("education_level")
school String?
isResetUser Boolean @default(false) @map("is_reset_user")
updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamptz()
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
}
3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express, { Request, Response } from "express"
import cors from "cors"
import bodyParser from "body-parser"
import routerManager from "@/router"
import { errorHandler } from "@/middleware/errorHandler"

const app = express()
const PORT = 8080
Expand All @@ -26,6 +27,8 @@ app.get("/health", (_req: Request, res: Response) => {
})
})

app.use(errorHandler)

app.listen(PORT, () => {
console.log(`Listening on port ${PORT}`)
})
40 changes: 40 additions & 0 deletions src/controller/auth/authController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { AppError } from "@/types/error/AppError"
import { AuthUsecase } from "@/usecase/auth/authUsecase"
import type { Request, Response } from "express"

export class AuthController {
private authUsecase: AuthUsecase

constructor(authUsecase: AuthUsecase) {
this.authUsecase = authUsecase
}

async login(req: Request, res: Response): Promise<void> {
try {
const keycloakUser = await this.authUsecase.getKeycloakUser(
req.body
)
const user = this.authUsecase.parseKeycloakUser(keycloakUser)
await this.authUsecase.register(user)

const token = await this.authUsecase.login(user)
res.cookie("token", token, {
maxAge: 3 * 24 * 60 * 60 * 1000,
httpOnly: true,
secure: process.env.NODE_ENV !== "development",
})
res.status(200).json({ token })
} catch (error) {
if (error instanceof AppError) {
res.status(error.statusCode).json({
message: error.message,
})
return
}
console.error("Login error:", error)
res.status(500).json({
message: "An unexpected error occurred",
})
}
}
}
57 changes: 57 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { KeycloakTokenResponse, LoginRequest } from "@/types/auth/POST"
import { ApiError } from "@/types/error/ApiError"
import { AppError } from "@/types/error/AppError"

const KEYCLOAK_API_BASE_URL =
process.env.KEYCLOAK_API_BASE_URL || "http://localhost:3000/realms/cucm25"

interface ApiResponseRaw {
message?: string
error?: string
}

export async function getKeycloakToken(body: LoginRequest): Promise<string> {
const url = `${KEYCLOAK_API_BASE_URL}/protocol/openid-connect/token`

try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
username: body.username,
password: body.password,
grant_type: "password",
scope: "openid profile email",
}),
})
if (!response.ok) {
let errorMsg = "Request failed"
if (response.body) {
try {
const responseText = await response.text()
const raw: ApiResponseRaw = JSON.parse(responseText)
errorMsg = raw.error || raw.message || errorMsg
} catch {
errorMsg = "Request failed"
}
}
throw new ApiError(errorMsg, response.status)
}

const data: KeycloakTokenResponse = await response.json()
return data.access_token
} catch (error) {
if (error instanceof ApiError) {
throw new AppError(error.message, error.status || 500)
}
console.error("Get Keycloak token error:", error)
throw new AppError(
error instanceof Error ? error.message : "Unknown error",
500
)
}
}
36 changes: 36 additions & 0 deletions src/middleware/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { jwtUser, verifyJwt } from "@/utils/jwt"
import { NextFunction, Request, Response } from "express"

declare module "express" {
interface Request {
user?: jwtUser
}
}

export function authMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization

if (!authHeader || !authHeader.startsWith("Bearer ")) {
res.status(401).json({ message: "Unauthorized: Token not provided" })
return
}

const token = authHeader.split(" ")[1]
if (!token) {
res.status(401).json({ message: "Unauthorized: Token not provided" })
return
}

try {
const decoded = verifyJwt(token)
req.user = decoded
next()
} catch (error) {
console.log("JWT verification error: ", error)
res.status(401).json({ message: "Unauthorized: Invalid token" })
}
}
20 changes: 20 additions & 0 deletions src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { AppError } from "@/types/error/AppError"
import { NextFunction, Request, Response } from "express"

export function errorHandler(
err: unknown,
_req: Request,
res: Response,
_next: NextFunction
): void {
if (err instanceof AppError) {
res.status(err.statusCode).json({
message: err.message,
})
return
}
console.error("Unexpected error occurred:", err)
res.status(500).json({
message: "An unexpected error occurred",
})
}
50 changes: 50 additions & 0 deletions src/repository/user/userRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { prisma } from "@/lib/prisma"
import { EducationLevel, RoleType, User } from "@prisma/client"

export interface parsedUser {
id: string
studentId: string
username: string
nickname: string
firstname: string
lastname: string
role: RoleType
educationLevel?: EducationLevel
school?: string
}

export class UserRepository {
async create(user: parsedUser): Promise<User> {
return await prisma.$transaction(async (tx) => {
const newUser = await tx.user.create({
data: {
id: user.id,
studentId: user.studentId,
username: user.username,
nickname: user.nickname,
firstname: user.firstname,
lastname: user.lastname,
role: user.role,
educationLevel: user.educationLevel || null,
school: user.school || null,
},
})

return newUser
})
}

async findExists(
user: Pick<parsedUser, "id" | "username">
): Promise<boolean> {
const existingUser = await prisma.user.findFirst({
where: {
AND: [{ id: user.id }, { username: user.username }],
},
})
if (existingUser) {
return true
}
return false
}
}
15 changes: 15 additions & 0 deletions src/router/authRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { AuthController } from "@/controller/auth/authController"
import { UserRepository } from "@/repository/user/userRepository"
import { AuthUsecase } from "@/usecase/auth/authUsecase"
import { Router } from "express"

export default function authRouter() {
const router = Router()
const userRepository = new UserRepository()
const authUsecase = new AuthUsecase(userRepository)
const authController = new AuthController(authUsecase)

router.post("/login", authController.login.bind(authController))

return router
}
2 changes: 2 additions & 0 deletions src/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Router } from "express"
import mockRouter from "@/router/mock"
import authRouter from "@/router/authRouter"

export default function routerManager() {
const router = Router()

router.use("/mock", mockRouter())
router.use("/auth", authRouter())

return router
}
16 changes: 16 additions & 0 deletions src/types/auth/POST.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface LoginRequest {
username: string
password: string
}

export interface KeycloakTokenResponse {
access_token: string
expires_in: number
refresh_expires_in: number
refresh_token: string
token_type: string
id_token: string
"not-before-policy": number
session_state: string
scope: string
}
10 changes: 10 additions & 0 deletions src/types/error/ApiError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export class ApiError extends Error {
constructor(
message: string,
public status?: number,
public response?: Response
) {
super(message)
this.name = "ApiError"
}
}
Loading