Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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