From d9f379150124724e320fee2ae8d3b6881e25ca60 Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Sat, 7 Feb 2026 19:57:29 +0100 Subject: [PATCH 1/5] feat: session token flow --- server/src/composition/composition.types.ts | 2 + server/src/composition/compositionRoot.ts | 5 + server/src/controllers/auth.controller.ts | 59 +++++++--- server/src/db/schemes/session.schema.ts | 23 ++++ server/src/db/schemes/types/session.types.ts | 10 ++ .../middlewares/authMiddlewareWithBearer.ts | 2 +- server/src/middlewares/global.middleware.ts | 15 ++- .../src/middlewares/verifyToken.middleware.ts | 7 +- .../refreshSession.repository.ts | 81 +++++++++++++ server/src/services/auth/auth.service.ts | 106 +++++++++++++++++- server/src/services/jwt/jwt.service.ts | 29 +++-- server/src/types/auth/auth.types.ts | 16 +++ server/src/utils/error.util.ts | 7 ++ 13 files changed, 319 insertions(+), 43 deletions(-) create mode 100644 server/src/db/schemes/session.schema.ts create mode 100644 server/src/db/schemes/types/session.types.ts create mode 100644 server/src/repositories/commandRepositories/refreshSession.repository.ts create mode 100644 server/src/types/auth/auth.types.ts diff --git a/server/src/composition/composition.types.ts b/server/src/composition/composition.types.ts index 72f0518..fee95fa 100644 --- a/server/src/composition/composition.types.ts +++ b/server/src/composition/composition.types.ts @@ -18,4 +18,6 @@ export const TYPES = { //auth AuthController: Symbol.for("AuthController"), AuthService: Symbol.for("AuthService"), + //refresh + RefreshSessionRepository: Symbol.for("RefreshSessionRepository"), }; diff --git a/server/src/composition/compositionRoot.ts b/server/src/composition/compositionRoot.ts index 5e1f8d1..4fee63f 100644 --- a/server/src/composition/compositionRoot.ts +++ b/server/src/composition/compositionRoot.ts @@ -14,6 +14,7 @@ import { JwtService } from "../services/jwt/jwt.service.js"; import { AuthMiddleware } from "../middlewares/authMiddlewareWithBearer.js"; import { AuthService } from "../services/auth/auth.service.js"; import { VerifyMiddleware } from "../middlewares/verifyToken.middleware.js"; +import { RefreshSessionRepository } from "../repositories/commandRepositories/refreshSession.repository.js"; export const container = new Container(); @@ -22,6 +23,10 @@ container.bind(TYPES.AuthController).to(AuthController); container.bind(TYPES.AuthService).to(AuthService); //jwt container.bind(TYPES.JwtService).to(JwtService); +//refresh +container + .bind(TYPES.RefreshSessionRepository) + .to(RefreshSessionRepository); //middleware container.bind(TYPES.AuthMiddleware).to(AuthMiddleware); container.bind(TYPES.VerifyMiddleware).to(VerifyMiddleware); diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index a1b3c3e..f8d1b7e 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -17,6 +17,7 @@ import { AuthService } from "../services/auth/auth.service.js"; import { JwtService } from "../services/jwt/jwt.service.js"; import { StudentQuery } from "../repositories/queryRepositories/student.query.js"; import { TeacherQuery } from "../repositories/queryRepositories/teacher.query.js"; +import { RefreshTokenPayload } from "../types/auth/auth.types.js"; @injectable() export class AuthController { @@ -80,17 +81,26 @@ export class AuthController { const { email, password } = req.body; try { - const user = await this.authService.checkAuthStudentCredentials( + const student = await this.authService.checkAuthStudentCredentials( email, password, ); - const accessToken = await this.jwtService.createJWTAccessToken(user); - const refreshToken = await this.jwtService.createJWTRefreshToken(user); + const accessToken = this.jwtService.createJWTAccessToken({ + userId: student.id, + role: "student", + }); + + const { refreshToken } = await this.authService.createRefreshSession({ + userId: student.id, + role: "student", + }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: true, + path: "/api/auth", + maxAge: 2 * 60 * 60 * 1000, }); res.status(200).send({ accessToken }); return; @@ -112,12 +122,21 @@ export class AuthController { password, ); - const accessToken = await this.jwtService.createJWTAccessToken(teacher); - const refreshToken = await this.jwtService.createJWTRefreshToken(teacher); + const accessToken = this.jwtService.createJWTAccessToken({ + userId: teacher.id, + role: "teacher", + }); + + const { refreshToken } = await this.authService.createRefreshSession({ + userId: teacher.id, + role: "teacher", + }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: true, + path: "/api/auth", + maxAge: 2 * 60 * 60 * 1000, }); res.status(200).send({ accessToken }); return; @@ -147,26 +166,30 @@ export class AuthController { async refreshController(req: Request, res: Response, next: NextFunction) { try { - const { userId, role } = req.auth!; - - const user = - role === "student" - ? await this.studentQuery.getStudentById(userId) - : await this.teacherQuery.getTeacherById(userId); - - if (!user) { + const refreshToken = req.cookies?.["refreshToken"]; + if (!refreshToken) { return res.sendStatus(401); } + let payload: RefreshTokenPayload; + try { + payload = this.jwtService.verifyRefreshToken(refreshToken); + } catch { + return res.sendStatus(401); + } + const { newAccessToken, newRefreshToken } = + await this.authService.rotateRefreshToken({ + refreshToken, + payload, + }); - const accessToken = await this.jwtService.createJWTAccessToken(user); - const refreshToken = await this.jwtService.createJWTRefreshToken(user); - - res.cookie("refreshToken", refreshToken, { + res.cookie("refreshToken", newRefreshToken, { httpOnly: true, secure: true, + path: "/api/auth", + maxAge: 2 * 60 * 60 * 1000, }); - return res.status(200).send({ accessToken }); + return res.status(200).send({ accessToken: newAccessToken }); } catch (e) { return next(e); } diff --git a/server/src/db/schemes/session.schema.ts b/server/src/db/schemes/session.schema.ts new file mode 100644 index 0000000..0beb1af --- /dev/null +++ b/server/src/db/schemes/session.schema.ts @@ -0,0 +1,23 @@ +import mongoose from "mongoose"; +import { RefreshSessionDB } from "./types/session.types.js"; + +const RefreshSessionSchema = new mongoose.Schema( + { + id: { type: String, required: true, unique: true, index: true }, + userId: { type: String, required: true, index: true }, + role: { type: String, required: true, enum: ["teacher", "student"] }, + refreshTokenHash: { type: String, required: true }, + + expiresAt: { type: Date, required: true, index: true }, + createdAt: { type: Date, default: Date.now }, + + revokedAt: { type: Date, default: null }, + replacedBySessionId: { type: String, default: null }, + }, + { versionKey: false }, +); +RefreshSessionSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +export const RefreshSessionModel = + mongoose.models.RefreshSession || + mongoose.model("RefreshSession", RefreshSessionSchema); diff --git a/server/src/db/schemes/types/session.types.ts b/server/src/db/schemes/types/session.types.ts new file mode 100644 index 0000000..08a46a0 --- /dev/null +++ b/server/src/db/schemes/types/session.types.ts @@ -0,0 +1,10 @@ +export type RefreshSessionDB = { + id: string; + userId: string; + role: "teacher" | "student"; + refreshTokenHash: string; + expiresAt: Date; + createdAt: Date; + revokedAt: Date | null; + replacedBySessionId: string | null; +}; diff --git a/server/src/middlewares/authMiddlewareWithBearer.ts b/server/src/middlewares/authMiddlewareWithBearer.ts index 798829e..da66cbf 100644 --- a/server/src/middlewares/authMiddlewareWithBearer.ts +++ b/server/src/middlewares/authMiddlewareWithBearer.ts @@ -18,7 +18,7 @@ export class AuthMiddleware { } const token = req.headers.authorization.split(" ")[1]; - const payload = await this.jwtService.verifyToken(token); + const payload = this.jwtService.verifyAccessToken(token); if (!payload?.userId || !payload?.role) { res.sendStatus(401); diff --git a/server/src/middlewares/global.middleware.ts b/server/src/middlewares/global.middleware.ts index 36a9776..eac0868 100644 --- a/server/src/middlewares/global.middleware.ts +++ b/server/src/middlewares/global.middleware.ts @@ -8,15 +8,18 @@ export const globalErrorMiddleware: ErrorRequestHandler = ( res, next, ) => { - logError(err); - - if (res.headersSent) return next(err); + if (res.headersSent) { + return next(err); + } if (err instanceof HttpError) { - return res.status(err.statusCode).json({ - message: err.message, - }); + if (err.statusCode >= 500) { + logError(err); + } + + return res.status(err.statusCode).json({ message: err.message }); } + logError(err); return res.status(500).json({ message: "Internal server error" }); }; diff --git a/server/src/middlewares/verifyToken.middleware.ts b/server/src/middlewares/verifyToken.middleware.ts index 17fac0b..45afad9 100644 --- a/server/src/middlewares/verifyToken.middleware.ts +++ b/server/src/middlewares/verifyToken.middleware.ts @@ -13,12 +13,15 @@ export class VerifyMiddleware { return res.sendStatus(401); } - const payload = await this.jwtService.verifyToken(token); + const payload = this.jwtService.verifyRefreshToken(token); if (!payload?.userId || !payload?.role) { return res.sendStatus(401); } - req.auth = { userId: payload.userId, role: payload.role }; + req.auth = { + userId: payload.userId, + role: payload.role, + }; return next(); }; } diff --git a/server/src/repositories/commandRepositories/refreshSession.repository.ts b/server/src/repositories/commandRepositories/refreshSession.repository.ts new file mode 100644 index 0000000..e350431 --- /dev/null +++ b/server/src/repositories/commandRepositories/refreshSession.repository.ts @@ -0,0 +1,81 @@ +import { RefreshSessionModel } from "../../db/schemes/session.schema.js"; +import { RefreshSessionDB } from "../../db/schemes/types/session.types.js"; +import { injectable } from "inversify"; +import { HttpError } from "../../utils/error.util.js"; + +@injectable() +export class RefreshSessionRepository { + async create(session: RefreshSessionDB): Promise { + try { + await RefreshSessionModel.create(session); + } catch (err: unknown) { + throw new HttpError(500, "Session was not created", { cause: err }); + } + } + + async findById(sessionId: string): Promise { + try { + return await RefreshSessionModel.findOne({ id: sessionId }).lean(); + } catch (err: unknown) { + throw new HttpError(500, "Session was not found", { cause: err }); + } + } + + async revoke( + sessionId: string, + revokedAt: Date = new Date(), + ): Promise { + try { + const res = await RefreshSessionModel.updateOne( + { id: sessionId, revokedAt: null }, + { $set: { revokedAt } }, + ); + return res.modifiedCount === 1; + } catch (err: unknown) { + throw new HttpError(500, "Session was not updated", { cause: err }); + } + } + + async revokeAllForUser( + userId: string, + role?: RefreshSessionDB["role"], + revokedAt: Date = new Date(), + ): Promise { + try { + const filter: Record = { userId, revokedAt: null }; + if (role) { + filter.role = role; + } + const res = await RefreshSessionModel.updateMany(filter, { + $set: { revokedAt }, + }); + + return res.modifiedCount; + } catch (err: unknown) { + throw new HttpError(500, "Sessions were not updated", { cause: err }); + } + } + + async replace(oldSessionId: string, newSessionId: string): Promise { + try { + const res = await RefreshSessionModel.updateOne( + { id: oldSessionId, revokedAt: null }, + { $set: { revokedAt: new Date(), replacedBySessionId: newSessionId } }, + ); + return res.modifiedCount === 1; + } catch (err: unknown) { + throw new HttpError(500, "Session was not updated", { cause: err }); + } + } + + async deleteExpiredNow(): Promise { + try { + const res = await RefreshSessionModel.deleteMany({ + expiresAt: { $lte: new Date() }, + }); + return res.deletedCount ?? 0; + } catch (err: unknown) { + throw new HttpError(500, "Session was not deleted", { cause: err }); + } + } +} diff --git a/server/src/services/auth/auth.service.ts b/server/src/services/auth/auth.service.ts index e12e5a9..1f44b38 100644 --- a/server/src/services/auth/auth.service.ts +++ b/server/src/services/auth/auth.service.ts @@ -1,17 +1,27 @@ import { inject, injectable } from "inversify"; import { StudentQuery } from "../../repositories/queryRepositories/student.query.js"; import { TYPES } from "../../composition/composition.types.js"; -import { HttpError } from "../../utils/error.util.js"; +import { HttpError, UnauthorizedError } from "../../utils/error.util.js"; import { studentMapper } from "../../utils/mappers/student.mapper.js"; import bcrypt from "bcryptjs"; import { TeacherQuery } from "../../repositories/queryRepositories/teacher.query.js"; import { teacherMapper } from "../../utils/mappers/teacher.mapper.js"; +import { createHash, randomUUID } from "node:crypto"; +import { JwtService } from "../jwt/jwt.service.js"; +import { RefreshSessionRepository } from "../../repositories/commandRepositories/refreshSession.repository.js"; +import { + RefreshTokenPayload, + RotateArgs, +} from "../../types/auth/auth.types.js"; @injectable() export class AuthService { constructor( @inject(TYPES.StudentQuery) private studentQuery: StudentQuery, @inject(TYPES.TeacherQuery) private teacherQuery: TeacherQuery, + @inject(TYPES.JwtService) protected jwtService: JwtService, + @inject(TYPES.RefreshSessionRepository) + protected refreshSessionRepository: RefreshSessionRepository, ) {} async checkAuthStudentCredentials(email: string, password: string) { @@ -52,6 +62,100 @@ export class AuthService { } } + async createRefreshSession({ + userId, + role, + }: { + userId: string; + role: "teacher" | "student"; + }) { + await this.refreshSessionRepository.revokeAllForUser(userId, role); + const sessionId = randomUUID(); + const refreshToken = this.jwtService.createJWTRefreshToken({ + userId, + role, + sessionId, + }); + + await this.refreshSessionRepository.create({ + id: sessionId, + userId, + role, + refreshTokenHash: this.sha256(refreshToken), + expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000), + createdAt: new Date(), + revokedAt: null, + replacedBySessionId: null, + }); + + return { refreshToken, sessionId }; + } + + private async assertRefreshSessionValid( + payload: RefreshTokenPayload, + refreshToken: string, + ) { + const session = await this.refreshSessionRepository.findById( + payload.sessionId, + ); + if (!session) { + throw new UnauthorizedError("Unauthorized"); + } + + if (session.revokedAt) { + await this.refreshSessionRepository.revokeAllForUser( + payload.userId, + payload.role, + ); + throw new UnauthorizedError("Unauthorized"); + } + + if (session.expiresAt.getTime() <= Date.now()) { + throw new UnauthorizedError("Unauthorized"); + } + + const tokenHash = this.sha256(refreshToken); + if (tokenHash !== session.refreshTokenHash) { + await this.refreshSessionRepository.revokeAllForUser( + payload.userId, + payload.role, + ); + throw new UnauthorizedError("Unauthorized"); + } + return session; + } + + async rotateRefreshToken({ refreshToken, payload }: RotateArgs) { + await this.assertRefreshSessionValid(payload, refreshToken); + + const newAccessToken = this.jwtService.createJWTAccessToken({ + userId: payload.userId, + role: payload.role, + }); + + const { refreshToken: newRefreshToken } = await this.createRefreshSession({ + userId: payload.userId, + role: payload.role, + }); + + return { newAccessToken, newRefreshToken }; + } + + async logoutByRefreshToken(refreshToken: string) { + let payload: RefreshTokenPayload | null = null; + + try { + payload = this.jwtService.verifyRefreshToken(refreshToken); + } catch { + return; + } + + await this.refreshSessionRepository.revoke(payload.sessionId); + } + + sha256(string: string) { + return createHash("sha256").update(string).digest("hex"); + } async _generateHash(password: string, salt: string) { return await bcrypt.hash(password, salt); } diff --git a/server/src/services/jwt/jwt.service.ts b/server/src/services/jwt/jwt.service.ts index 52506b8..bf236ab 100644 --- a/server/src/services/jwt/jwt.service.ts +++ b/server/src/services/jwt/jwt.service.ts @@ -1,7 +1,6 @@ import { injectable } from "inversify"; import jwt from "jsonwebtoken"; -import { StudentViewType } from "../../types/student/student.types.js"; -import { TeacherViewType } from "../../types/teacher/teacher.types.js"; +import { RefreshTokenPayload } from "../../types/auth/auth.types.js"; type AccessTokenPayload = { userId: string; role: "student" | "teacher"; @@ -11,29 +10,29 @@ export class JwtService { secret = "1234"; constructor() {} - async createJWTAccessToken( - user: StudentViewType | TeacherViewType, - ): Promise { - return jwt.sign({ userId: user.id, role: user.role }, this.secret, { + createJWTAccessToken({ userId, role }: AccessTokenPayload): string { + return jwt.sign({ userId, role }, this.secret, { expiresIn: "1h", }); } - async createJWTRefreshToken(user: StudentViewType | TeacherViewType) { + createJWTRefreshToken({ userId, role, sessionId }: RefreshTokenPayload) { return jwt.sign( { - userId: user.id, - role: user.role, + userId, + role, + sessionId, }, this.secret, { expiresIn: "2h" }, ); } - async verifyToken(token: string): Promise { - try { - return jwt.verify(token, this.secret) as AccessTokenPayload; - } catch { - return null; - } + + verifyAccessToken(token: string): AccessTokenPayload { + return jwt.verify(token, this.secret) as AccessTokenPayload; + } + + verifyRefreshToken(token: string): RefreshTokenPayload { + return jwt.verify(token, this.secret) as RefreshTokenPayload; } } diff --git a/server/src/types/auth/auth.types.ts b/server/src/types/auth/auth.types.ts new file mode 100644 index 0000000..c9baf1f --- /dev/null +++ b/server/src/types/auth/auth.types.ts @@ -0,0 +1,16 @@ +export type Role = "teacher" | "student"; +export type RefreshTokenPayload = { + userId: string; + role: Role; + sessionId: string; +}; + +export type AccessTokenPayload = { + userId: string; + role: Role; +}; + +export type RotateArgs = { + refreshToken: string; + payload: RefreshTokenPayload; +}; diff --git a/server/src/utils/error.util.ts b/server/src/utils/error.util.ts index f5fc37e..c6f23cd 100644 --- a/server/src/utils/error.util.ts +++ b/server/src/utils/error.util.ts @@ -15,3 +15,10 @@ export class NotFoundError extends HttpError { this.name = "NotFoundError"; } } + +export class UnauthorizedError extends HttpError { + constructor(message = "Unauthorized", details?: unknown) { + super(401, message, details); + this.name = "UnauthorizedError"; + } +} From 44662a438e3ebb311c734eab7072617a55a4881b Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:43:08 +0100 Subject: [PATCH 2/5] fix: problem with new session --- server/src/controllers/auth.controller.ts | 2 +- server/src/db/schemes/types/session.types.ts | 3 +++ .../refreshSession.repository.ts | 9 ++++++++- server/src/routes/authRoute.ts | 6 +----- server/src/services/auth/auth.service.ts | 15 ++++++++++----- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index f8d1b7e..b9d92e0 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -168,7 +168,7 @@ export class AuthController { try { const refreshToken = req.cookies?.["refreshToken"]; if (!refreshToken) { - return res.sendStatus(401); + return res.sendStatus(204); } let payload: RefreshTokenPayload; try { diff --git a/server/src/db/schemes/types/session.types.ts b/server/src/db/schemes/types/session.types.ts index 08a46a0..e7351fa 100644 --- a/server/src/db/schemes/types/session.types.ts +++ b/server/src/db/schemes/types/session.types.ts @@ -8,3 +8,6 @@ export type RefreshSessionDB = { revokedAt: Date | null; replacedBySessionId: string | null; }; +export type RefreshSessionPatch = Partial< + Omit +>; diff --git a/server/src/repositories/commandRepositories/refreshSession.repository.ts b/server/src/repositories/commandRepositories/refreshSession.repository.ts index e350431..af0d838 100644 --- a/server/src/repositories/commandRepositories/refreshSession.repository.ts +++ b/server/src/repositories/commandRepositories/refreshSession.repository.ts @@ -1,5 +1,8 @@ import { RefreshSessionModel } from "../../db/schemes/session.schema.js"; -import { RefreshSessionDB } from "../../db/schemes/types/session.types.js"; +import { + RefreshSessionDB, + RefreshSessionPatch, +} from "../../db/schemes/types/session.types.js"; import { injectable } from "inversify"; import { HttpError } from "../../utils/error.util.js"; @@ -46,6 +49,7 @@ export class RefreshSessionRepository { if (role) { filter.role = role; } + const res = await RefreshSessionModel.updateMany(filter, { $set: { revokedAt }, }); @@ -56,6 +60,9 @@ export class RefreshSessionRepository { } } + updateById(id: string, patch: RefreshSessionPatch) { + return RefreshSessionModel.updateOne({ id }, { $set: patch }); + } async replace(oldSessionId: string, newSessionId: string): Promise { try { const res = await RefreshSessionModel.updateOne( diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index bdfc097..f7c17da 100644 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -9,14 +9,11 @@ import { container } from "../composition/compositionRoot.js"; import { AuthController } from "../controllers/auth.controller.js"; import { TYPES } from "../composition/composition.types.js"; import { AuthMiddleware } from "../middlewares/authMiddlewareWithBearer.js"; -import { VerifyMiddleware } from "../middlewares/verifyToken.middleware.js"; export const authRouter = Router(); const authController = container.get(TYPES.AuthController); const authMiddleware = container.get(TYPES.AuthMiddleware); -const verifyTokenMiddleware = container.get( - TYPES.VerifyMiddleware, -); + authRouter.post( "/registration-student", autStudentValidationMiddleware(), @@ -53,6 +50,5 @@ authRouter.get( authRouter.post( "/refresh-token", - verifyTokenMiddleware.verify, authController.refreshController.bind(authController), ); diff --git a/server/src/services/auth/auth.service.ts b/server/src/services/auth/auth.service.ts index 1f44b38..409ccff 100644 --- a/server/src/services/auth/auth.service.ts +++ b/server/src/services/auth/auth.service.ts @@ -69,7 +69,6 @@ export class AuthService { userId: string; role: "teacher" | "student"; }) { - await this.refreshSessionRepository.revokeAllForUser(userId, role); const sessionId = randomUUID(); const refreshToken = this.jwtService.createJWTRefreshToken({ userId, @@ -126,16 +125,23 @@ export class AuthService { } async rotateRefreshToken({ refreshToken, payload }: RotateArgs) { - await this.assertRefreshSessionValid(payload, refreshToken); - + const session = await this.assertRefreshSessionValid(payload, refreshToken); const newAccessToken = this.jwtService.createJWTAccessToken({ userId: payload.userId, role: payload.role, }); - const { refreshToken: newRefreshToken } = await this.createRefreshSession({ + const newRefreshToken = this.jwtService.createJWTRefreshToken({ userId: payload.userId, role: payload.role, + sessionId: session.id, + }); + + await this.refreshSessionRepository.updateById(session.id, { + refreshTokenHash: this.sha256(newRefreshToken), + expiresAt: new Date(Date.now() + 2 * 60 * 60 * 1000), + revokedAt: null, + replacedBySessionId: null, }); return { newAccessToken, newRefreshToken }; @@ -149,7 +155,6 @@ export class AuthService { } catch { return; } - await this.refreshSessionRepository.revoke(payload.sessionId); } From 96eec2113f8fce42cba804284508b84ddcd4a363 Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Sun, 8 Feb 2026 10:58:50 +0100 Subject: [PATCH 3/5] fix: refresh error --- server/src/controllers/auth.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index b9d92e0..f8d1b7e 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -168,7 +168,7 @@ export class AuthController { try { const refreshToken = req.cookies?.["refreshToken"]; if (!refreshToken) { - return res.sendStatus(204); + return res.sendStatus(401); } let payload: RefreshTokenPayload; try { From 748d4e4ac09091d36e316e43efca3800041b2c97 Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:44:29 +0100 Subject: [PATCH 4/5] feat: refresh token middleware --- ...ginStudentMutation.ts => useLoginMutation.ts} | 1 + ...StudentMutation.ts => useRegisterMutation.ts} | 2 +- client/src/features/auth/query/useAuthInit.tsx | 6 ++++++ client/src/pages/loginPage/LoginPage.tsx | 2 +- client/src/pages/signUpPage/SignUpPage.tsx | 4 ++-- server/index.d.ts | 1 + server/src/composition/composition.types.ts | 2 +- server/src/composition/compositionRoot.ts | 6 ++++-- server/src/controllers/auth.controller.ts | 14 ++------------ server/src/db/schemes/session.schema.ts | 2 +- ....middleware.ts => refreshToken.middleware.ts} | 16 ++++++---------- server/src/routes/authRoute.ts | 5 +++++ 12 files changed, 31 insertions(+), 30 deletions(-) rename client/src/features/auth/mutations/{useLoginStudentMutation.ts => useLoginMutation.ts} (95%) rename client/src/features/auth/mutations/{useRegisterStudentMutation.ts => useRegisterMutation.ts} (94%) rename server/src/middlewares/{verifyToken.middleware.ts => refreshToken.middleware.ts} (70%) diff --git a/client/src/features/auth/mutations/useLoginStudentMutation.ts b/client/src/features/auth/mutations/useLoginMutation.ts similarity index 95% rename from client/src/features/auth/mutations/useLoginStudentMutation.ts rename to client/src/features/auth/mutations/useLoginMutation.ts index f8a6bc3..1c1da2c 100644 --- a/client/src/features/auth/mutations/useLoginStudentMutation.ts +++ b/client/src/features/auth/mutations/useLoginMutation.ts @@ -18,6 +18,7 @@ export function useLoginMutation(role: Role) { mutationFn, onSuccess: async ({ accessToken }) => { setAccessToken(accessToken); + localStorage.setItem("hadSession", "1"); navigate("/", { replace: true }); await qc.invalidateQueries({ queryKey: queryKeys.me }); }, diff --git a/client/src/features/auth/mutations/useRegisterStudentMutation.ts b/client/src/features/auth/mutations/useRegisterMutation.ts similarity index 94% rename from client/src/features/auth/mutations/useRegisterStudentMutation.ts rename to client/src/features/auth/mutations/useRegisterMutation.ts index 0c523c7..779bf6f 100644 --- a/client/src/features/auth/mutations/useRegisterStudentMutation.ts +++ b/client/src/features/auth/mutations/useRegisterMutation.ts @@ -7,7 +7,7 @@ import { queryKeys } from "../../queryKeys"; import { getErrorMessage } from "../../../util/ErrorUtil"; import { RegisterFinalType, Role } from "../../../api/auth/types"; -export const useRegisterStudentMutation = (role: Role) => { +export const useRegisterMutation = (role: Role) => { const qc = useQueryClient(); const mutationFn = (data: RegisterFinalType) => { diff --git a/client/src/features/auth/query/useAuthInit.tsx b/client/src/features/auth/query/useAuthInit.tsx index 4a2f51e..0097f7a 100644 --- a/client/src/features/auth/query/useAuthInit.tsx +++ b/client/src/features/auth/query/useAuthInit.tsx @@ -7,11 +7,17 @@ export const useAuthInit = () => { const clearSession = useAuthSessionStore((s) => s.clearSession); useEffect(() => { + const hadSession = localStorage.getItem("hadSession") === "1"; + if (!hadSession) { + clearSession(); + return; + } (async () => { try { const { accessToken } = await refreshApi(); setAccessToken(accessToken); } catch { + localStorage.removeItem("hadSession"); clearSession(); } })(); diff --git a/client/src/pages/loginPage/LoginPage.tsx b/client/src/pages/loginPage/LoginPage.tsx index 7346c32..939b057 100644 --- a/client/src/pages/loginPage/LoginPage.tsx +++ b/client/src/pages/loginPage/LoginPage.tsx @@ -1,6 +1,6 @@ import { LoginFinalType, Role } from "../../api/auth/types"; import { LoginForm } from "../../components/auth/loginForm/LoginForm"; -import { useLoginMutation } from "../../features/auth/mutations/useLoginStudentMutation"; +import { useLoginMutation } from "../../features/auth/mutations/useLoginMutation"; export const LoginPage = ({ role }: { role: Role }) => { const { mutateAsync, isPending } = useLoginMutation(role); diff --git a/client/src/pages/signUpPage/SignUpPage.tsx b/client/src/pages/signUpPage/SignUpPage.tsx index b60ca38..0668a18 100644 --- a/client/src/pages/signUpPage/SignUpPage.tsx +++ b/client/src/pages/signUpPage/SignUpPage.tsx @@ -1,9 +1,9 @@ import { SignUpForm } from "../../components/auth/signUpForm/SignUpForm"; import { RegisterFinalType, Role } from "../../api/auth/types"; -import { useRegisterStudentMutation } from "../../features/auth/mutations/useRegisterStudentMutation"; +import { useRegisterMutation } from "../../features/auth/mutations/useRegisterMutation"; export const SignUpPage = ({ role }: { role: Role }) => { - const { mutateAsync, isPending } = useRegisterStudentMutation(role); + const { mutateAsync, isPending } = useRegisterMutation(role); const onSubmit = (data: RegisterFinalType) => { mutateAsync({ ...data, role }); diff --git a/server/index.d.ts b/server/index.d.ts index 7172cb3..422ca2e 100644 --- a/server/index.d.ts +++ b/server/index.d.ts @@ -3,6 +3,7 @@ export declare global { namespace Express { export interface Request { auth?: { userId: string; role: Role }; + refresh?: { token: string; payload: RefreshTokenPayload }; } } } diff --git a/server/src/composition/composition.types.ts b/server/src/composition/composition.types.ts index fee95fa..54ab603 100644 --- a/server/src/composition/composition.types.ts +++ b/server/src/composition/composition.types.ts @@ -14,7 +14,7 @@ export const TYPES = { JwtService: Symbol.for("JwtService"), //middlewares AuthMiddleware: Symbol.for("AuthMiddleware"), - VerifyMiddleware: Symbol.for("VerifyMiddleware"), + RefreshTokenMiddleware: Symbol.for("RefreshTokenMiddleware"), //auth AuthController: Symbol.for("AuthController"), AuthService: Symbol.for("AuthService"), diff --git a/server/src/composition/compositionRoot.ts b/server/src/composition/compositionRoot.ts index 4fee63f..25347c7 100644 --- a/server/src/composition/compositionRoot.ts +++ b/server/src/composition/compositionRoot.ts @@ -13,8 +13,8 @@ import { TeacherController } from "../controllers/teacher.controller.js"; import { JwtService } from "../services/jwt/jwt.service.js"; import { AuthMiddleware } from "../middlewares/authMiddlewareWithBearer.js"; import { AuthService } from "../services/auth/auth.service.js"; -import { VerifyMiddleware } from "../middlewares/verifyToken.middleware.js"; import { RefreshSessionRepository } from "../repositories/commandRepositories/refreshSession.repository.js"; +import { RefreshTokenMiddleware } from "../middlewares/refreshToken.middleware.js"; export const container = new Container(); @@ -29,7 +29,9 @@ container .to(RefreshSessionRepository); //middleware container.bind(TYPES.AuthMiddleware).to(AuthMiddleware); -container.bind(TYPES.VerifyMiddleware).to(VerifyMiddleware); +container + .bind(TYPES.RefreshTokenMiddleware) + .to(RefreshTokenMiddleware); // student container.bind(TYPES.StudentCommand).to(StudentCommand); diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index f8d1b7e..c9706f5 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -17,7 +17,6 @@ import { AuthService } from "../services/auth/auth.service.js"; import { JwtService } from "../services/jwt/jwt.service.js"; import { StudentQuery } from "../repositories/queryRepositories/student.query.js"; import { TeacherQuery } from "../repositories/queryRepositories/teacher.query.js"; -import { RefreshTokenPayload } from "../types/auth/auth.types.js"; @injectable() export class AuthController { @@ -166,19 +165,10 @@ export class AuthController { async refreshController(req: Request, res: Response, next: NextFunction) { try { - const refreshToken = req.cookies?.["refreshToken"]; - if (!refreshToken) { - return res.sendStatus(401); - } - let payload: RefreshTokenPayload; - try { - payload = this.jwtService.verifyRefreshToken(refreshToken); - } catch { - return res.sendStatus(401); - } + const { token, payload } = req.refresh!; const { newAccessToken, newRefreshToken } = await this.authService.rotateRefreshToken({ - refreshToken, + refreshToken: token, payload, }); diff --git a/server/src/db/schemes/session.schema.ts b/server/src/db/schemes/session.schema.ts index 0beb1af..b2a7efd 100644 --- a/server/src/db/schemes/session.schema.ts +++ b/server/src/db/schemes/session.schema.ts @@ -8,7 +8,7 @@ const RefreshSessionSchema = new mongoose.Schema( role: { type: String, required: true, enum: ["teacher", "student"] }, refreshTokenHash: { type: String, required: true }, - expiresAt: { type: Date, required: true, index: true }, + expiresAt: { type: Date, required: true }, createdAt: { type: Date, default: Date.now }, revokedAt: { type: Date, default: null }, diff --git a/server/src/middlewares/verifyToken.middleware.ts b/server/src/middlewares/refreshToken.middleware.ts similarity index 70% rename from server/src/middlewares/verifyToken.middleware.ts rename to server/src/middlewares/refreshToken.middleware.ts index 45afad9..df6a5ba 100644 --- a/server/src/middlewares/verifyToken.middleware.ts +++ b/server/src/middlewares/refreshToken.middleware.ts @@ -1,27 +1,23 @@ -import { inject, injectable } from "inversify"; import { TYPES } from "../composition/composition.types.js"; +import { inject, injectable } from "inversify"; import { JwtService } from "../services/jwt/jwt.service.js"; import { NextFunction, Request, Response } from "express"; - @injectable() -export class VerifyMiddleware { +export class RefreshTokenMiddleware { constructor(@inject(TYPES.JwtService) private jwtService: JwtService) {} - verify = async (req: Request, res: Response, next: NextFunction) => { + handle = (req: Request, res: Response, next: NextFunction) => { const token = req.cookies?.refreshToken; if (!token) { return res.sendStatus(401); } const payload = this.jwtService.verifyRefreshToken(token); - - if (!payload?.userId || !payload?.role) { + if (!payload) { return res.sendStatus(401); } - req.auth = { - userId: payload.userId, - role: payload.role, - }; + + req.refresh = { token, payload }; return next(); }; } diff --git a/server/src/routes/authRoute.ts b/server/src/routes/authRoute.ts index f7c17da..5ed7b0a 100644 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -9,10 +9,14 @@ import { container } from "../composition/compositionRoot.js"; import { AuthController } from "../controllers/auth.controller.js"; import { TYPES } from "../composition/composition.types.js"; import { AuthMiddleware } from "../middlewares/authMiddlewareWithBearer.js"; +import { RefreshTokenMiddleware } from "../middlewares/refreshToken.middleware.js"; export const authRouter = Router(); const authController = container.get(TYPES.AuthController); const authMiddleware = container.get(TYPES.AuthMiddleware); +const refreshTokenMiddleware = container.get( + TYPES.RefreshTokenMiddleware, +); authRouter.post( "/registration-student", @@ -50,5 +54,6 @@ authRouter.get( authRouter.post( "/refresh-token", + refreshTokenMiddleware.handle, authController.refreshController.bind(authController), ); From 7261ecd0a3f55eecb08c8c68a91af5e581f2ea8e Mon Sep 17 00:00:00 2001 From: Dmytro Doronin <138795636+Dmytro-Doronin@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:04:02 +0100 Subject: [PATCH 5/5] fix: remove hadSession --- client/src/features/auth/query/useAuthInit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/features/auth/query/useAuthInit.tsx b/client/src/features/auth/query/useAuthInit.tsx index 30fae70..0097f7a 100644 --- a/client/src/features/auth/query/useAuthInit.tsx +++ b/client/src/features/auth/query/useAuthInit.tsx @@ -16,8 +16,8 @@ export const useAuthInit = () => { try { const { accessToken } = await refreshApi(); setAccessToken(accessToken); - localStorage.removeItem("hadSession"); } catch { + localStorage.removeItem("hadSession"); clearSession(); } })();