diff --git a/client/src/features/auth/mutations/useLoginStudentMutation.ts b/client/src/features/auth/mutations/useLoginMutation.ts similarity index 100% rename from client/src/features/auth/mutations/useLoginStudentMutation.ts rename to client/src/features/auth/mutations/useLoginMutation.ts index 5a2fc98..1c1da2c 100644 --- a/client/src/features/auth/mutations/useLoginStudentMutation.ts +++ b/client/src/features/auth/mutations/useLoginMutation.ts @@ -18,8 +18,8 @@ export function useLoginMutation(role: Role) { mutationFn, onSuccess: async ({ accessToken }) => { setAccessToken(accessToken); - navigate("/", { replace: true }); localStorage.setItem("hadSession", "1"); + navigate("/", { replace: true }); await qc.invalidateQueries({ queryKey: queryKeys.me }); }, onError: (error) => { 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 9af315b..0097f7a 100644 --- a/client/src/features/auth/query/useAuthInit.tsx +++ b/client/src/features/auth/query/useAuthInit.tsx @@ -12,7 +12,6 @@ export const useAuthInit = () => { clearSession(); return; } - (async () => { try { const { accessToken } = await refreshApi(); 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 72f0518..54ab603 100644 --- a/server/src/composition/composition.types.ts +++ b/server/src/composition/composition.types.ts @@ -14,8 +14,10 @@ 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"), + //refresh + RefreshSessionRepository: Symbol.for("RefreshSessionRepository"), }; diff --git a/server/src/composition/compositionRoot.ts b/server/src/composition/compositionRoot.ts index 5e1f8d1..25347c7 100644 --- a/server/src/composition/compositionRoot.ts +++ b/server/src/composition/compositionRoot.ts @@ -13,7 +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(); @@ -22,9 +23,15 @@ 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); +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 a1b3c3e..c9706f5 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -80,17 +80,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 +121,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 +165,21 @@ 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) { - return res.sendStatus(401); - } - - const accessToken = await this.jwtService.createJWTAccessToken(user); - const refreshToken = await this.jwtService.createJWTRefreshToken(user); - - res.cookie("refreshToken", refreshToken, { + const { token, payload } = req.refresh!; + const { newAccessToken, newRefreshToken } = + await this.authService.rotateRefreshToken({ + refreshToken: token, + payload, + }); + + 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..b2a7efd --- /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 }, + 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..e7351fa --- /dev/null +++ b/server/src/db/schemes/types/session.types.ts @@ -0,0 +1,13 @@ +export type RefreshSessionDB = { + id: string; + userId: string; + role: "teacher" | "student"; + refreshTokenHash: string; + expiresAt: Date; + createdAt: Date; + revokedAt: Date | null; + replacedBySessionId: string | null; +}; +export type RefreshSessionPatch = Partial< + Omit +>; 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/refreshToken.middleware.ts similarity index 63% rename from server/src/middlewares/verifyToken.middleware.ts rename to server/src/middlewares/refreshToken.middleware.ts index 17fac0b..df6a5ba 100644 --- a/server/src/middlewares/verifyToken.middleware.ts +++ b/server/src/middlewares/refreshToken.middleware.ts @@ -1,24 +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 = await this.jwtService.verifyToken(token); - - if (!payload?.userId || !payload?.role) { + const payload = this.jwtService.verifyRefreshToken(token); + 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/repositories/commandRepositories/refreshSession.repository.ts b/server/src/repositories/commandRepositories/refreshSession.repository.ts new file mode 100644 index 0000000..af0d838 --- /dev/null +++ b/server/src/repositories/commandRepositories/refreshSession.repository.ts @@ -0,0 +1,88 @@ +import { RefreshSessionModel } from "../../db/schemes/session.schema.js"; +import { + RefreshSessionDB, + RefreshSessionPatch, +} 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 }); + } + } + + updateById(id: string, patch: RefreshSessionPatch) { + return RefreshSessionModel.updateOne({ id }, { $set: patch }); + } + 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/routes/authRoute.ts b/server/src/routes/authRoute.ts index bdfc097..5ed7b0a 100644 --- a/server/src/routes/authRoute.ts +++ b/server/src/routes/authRoute.ts @@ -9,14 +9,15 @@ 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"; +import { RefreshTokenMiddleware } from "../middlewares/refreshToken.middleware.js"; export const authRouter = Router(); const authController = container.get(TYPES.AuthController); const authMiddleware = container.get(TYPES.AuthMiddleware); -const verifyTokenMiddleware = container.get( - TYPES.VerifyMiddleware, +const refreshTokenMiddleware = container.get( + TYPES.RefreshTokenMiddleware, ); + authRouter.post( "/registration-student", autStudentValidationMiddleware(), @@ -53,6 +54,6 @@ authRouter.get( authRouter.post( "/refresh-token", - verifyTokenMiddleware.verify, + refreshTokenMiddleware.handle, authController.refreshController.bind(authController), ); diff --git a/server/src/services/auth/auth.service.ts b/server/src/services/auth/auth.service.ts index e12e5a9..409ccff 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,105 @@ export class AuthService { } } + async createRefreshSession({ + userId, + role, + }: { + userId: string; + role: "teacher" | "student"; + }) { + 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) { + const session = await this.assertRefreshSessionValid(payload, refreshToken); + const newAccessToken = this.jwtService.createJWTAccessToken({ + userId: payload.userId, + role: payload.role, + }); + + 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 }; + } + + 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"; + } +}