diff --git a/backend/package-lock.json b/backend/package-lock.json index b260dfa7..d45739cd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "content-disposition": "^0.5.4", + "cookie-parser": "^1.4.6", "mime-types": "^2.1.35", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", @@ -42,6 +43,7 @@ "@nestjs/schematics": "^9.0.3", "@nestjs/testing": "^9.2.1", "@types/archiver": "^5.3.1", + "@types/cookie-parser": "^1.4.3", "@types/cron": "^2.0.0", "@types/express": "^4.17.14", "@types/mime-types": "^2.1.1", @@ -1151,6 +1153,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz", + "integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -2635,6 +2646,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -8413,6 +8444,15 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.3.tgz", + "integrity": "sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -9570,6 +9610,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/backend/package.json b/backend/package.json index e48cee30..b44fb0e5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,6 +28,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.13.2", "content-disposition": "^0.5.4", + "cookie-parser": "^1.4.6", "mime-types": "^2.1.35", "moment": "^2.29.4", "multer": "^1.4.5-lts.1", @@ -47,6 +48,7 @@ "@nestjs/schematics": "^9.0.3", "@nestjs/testing": "^9.2.1", "@types/archiver": "^5.3.1", + "@types/cookie-parser": "^1.4.3", "@types/cron": "^2.0.0", "@types/express": "^4.17.14", "@types/mime-types": "^2.1.1", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9a315368..35ca29d2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -27,7 +27,8 @@ model User { } model RefreshToken { - token String @id @default(uuid()) + id String @id @default(uuid()) + token String @unique @default(uuid()) createdAt DateTime @default(now()) expiresAt DateTime diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index f6db99a3..228311f5 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -5,10 +5,14 @@ import { HttpCode, Patch, Post, + Req, + Res, + UnauthorizedException, UseGuards, } from "@nestjs/common"; import { Throttle } from "@nestjs/throttler"; import { User } from "@prisma/client"; +import { Request, Response } from "express"; import { ConfigService } from "src/config/config.service"; import { AuthService } from "./auth.service"; import { AuthTotpService } from "./authTotp.service"; @@ -17,7 +21,6 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { EnableTotpDTO } from "./dto/enableTotp.dto"; -import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; import { JwtGuard } from "./guard/jwt.guard"; @@ -32,24 +35,59 @@ export class AuthController { @Throttle(10, 5 * 60) @Post("signUp") - async signUp(@Body() dto: AuthRegisterDTO) { + async signUp( + @Body() dto: AuthRegisterDTO, + @Res({ passthrough: true }) response: Response + ) { if (!this.config.get("ALLOW_REGISTRATION")) throw new ForbiddenException("Registration is not allowed"); - return this.authService.signUp(dto); + const result = await this.authService.signUp(dto); + + response = this.addTokensToResponse( + response, + result.accessToken, + result.refreshToken + ); + + return result; } @Throttle(10, 5 * 60) @Post("signIn") @HttpCode(200) - signIn(@Body() dto: AuthSignInDTO) { - return this.authService.signIn(dto); + async signIn( + @Body() dto: AuthSignInDTO, + @Res({ passthrough: true }) response: Response + ) { + const result = await this.authService.signIn(dto); + + if (result.accessToken && result.refreshToken) { + response = this.addTokensToResponse( + response, + result.accessToken, + result.refreshToken + ); + } + + return result; } @Throttle(10, 5 * 60) @Post("signIn/totp") @HttpCode(200) - signInTotp(@Body() dto: AuthSignInTotpDTO) { - return this.authTotpService.signInTotp(dto); + async signInTotp( + @Body() dto: AuthSignInTotpDTO, + @Res({ passthrough: true }) response: Response + ) { + const result = await this.authTotpService.signInTotp(dto); + + response = this.addTokensToResponse( + response, + result.accessToken, + result.refreshToken + ); + + return result; } @Patch("password") @@ -60,13 +98,33 @@ export class AuthController { @Post("token") @HttpCode(200) - async refreshAccessToken(@Body() body: RefreshAccessTokenDTO) { + async refreshAccessToken( + @Req() request: Request, + @Res({ passthrough: true }) response: Response + ) { + if (!request.cookies.refresh_token) throw new UnauthorizedException(); + const accessToken = await this.authService.refreshAccessToken( - body.refreshToken + request.cookies.refresh_token ); + response.cookie("access_token", accessToken, { httpOnly: true }); return { accessToken }; } + @Post("signOut") + async signOut( + @Req() request: Request, + @Res({ passthrough: true }) response: Response + ) { + await this.authService.signOut(request.cookies.access_token); + response.cookie("access_token", "accessToken", { maxAge: -1 }); + response.cookie("refresh_token", "", { + path: "/api/auth/token", + httpOnly: true, + maxAge: -1, + }); + } + @Post("totp/enable") @UseGuards(JwtGuard) async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) { @@ -85,4 +143,19 @@ export class AuthController { // Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code return this.authTotpService.disableTotp(user, body.password, body.code); } + + private addTokensToResponse( + response: Response, + accessToken: string, + refreshToken: string + ) { + response.cookie("access_token", accessToken); + response.cookie("refresh_token", refreshToken, { + path: "/api/auth/token", + httpOnly: true, + maxAge: 60 * 60 * 24 * 30 * 3, + }); + + return response; + } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 9b370412..b6b447e0 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -34,8 +34,10 @@ export class AuthService { }, }); - const accessToken = await this.createAccessToken(user); - const refreshToken = await this.createRefreshToken(user.id); + const { refreshToken, refreshTokenId } = await this.createRefreshToken( + user.id + ); + const accessToken = await this.createAccessToken(user, refreshTokenId); return { accessToken, refreshToken }; } catch (e) { @@ -71,8 +73,10 @@ export class AuthService { return { loginToken }; } - const accessToken = await this.createAccessToken(user); - const refreshToken = await this.createRefreshToken(user.id); + const { refreshToken, refreshTokenId } = await this.createRefreshToken( + user.id + ); + const accessToken = await this.createAccessToken(user, refreshTokenId); return { accessToken, refreshToken }; } @@ -89,11 +93,12 @@ export class AuthService { }); } - async createAccessToken(user: User) { + async createAccessToken(user: User, refreshTokenId: string) { return this.jwtService.sign( { sub: user.id, email: user.email, + refreshTokenId, }, { expiresIn: "15min", @@ -102,6 +107,14 @@ export class AuthService { ); } + async signOut(accessToken: string) { + const { refreshTokenId } = this.jwtService.decode(accessToken) as { + refreshTokenId: string; + }; + + await this.prisma.refreshToken.delete({ where: { id: refreshTokenId } }); + } + async refreshAccessToken(refreshToken: string) { const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({ where: { token: refreshToken }, @@ -111,17 +124,18 @@ export class AuthService { if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date()) throw new UnauthorizedException(); - return this.createAccessToken(refreshTokenMetaData.user); + return this.createAccessToken( + refreshTokenMetaData.user, + refreshTokenMetaData.id + ); } async createRefreshToken(userId: string) { - const refreshToken = ( - await this.prisma.refreshToken.create({ - data: { userId, expiresAt: moment().add(3, "months").toDate() }, - }) - ).token; + const { id, token } = await this.prisma.refreshToken.create({ + data: { userId, expiresAt: moment().add(3, "months").toDate() }, + }); - return refreshToken; + return { refreshTokenId: id, refreshToken: token }; } async createLoginToken(userId: string) { diff --git a/backend/src/auth/authTotp.service.ts b/backend/src/auth/authTotp.service.ts index 883659dd..bc907019 100644 --- a/backend/src/auth/authTotp.service.ts +++ b/backend/src/auth/authTotp.service.ts @@ -71,8 +71,12 @@ export class AuthTotpService { data: { used: true }, }); - const accessToken = await this.authService.createAccessToken(user); - const refreshToken = await this.authService.createRefreshToken(user.id); + const { refreshToken, refreshTokenId } = + await this.authService.createRefreshToken(user.id); + const accessToken = await this.authService.createAccessToken( + user, + refreshTokenId + ); return { accessToken, refreshToken }; } diff --git a/backend/src/auth/dto/refreshAccessToken.dto.ts b/backend/src/auth/dto/refreshAccessToken.dto.ts deleted file mode 100644 index db7aa417..00000000 --- a/backend/src/auth/dto/refreshAccessToken.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { IsNotEmpty } from "class-validator"; - -export class RefreshAccessTokenDTO { - @IsNotEmpty() - refreshToken: string; -} diff --git a/backend/src/auth/strategy/jwt.strategy.ts b/backend/src/auth/strategy/jwt.strategy.ts index d4bed844..5ed085a3 100644 --- a/backend/src/auth/strategy/jwt.strategy.ts +++ b/backend/src/auth/strategy/jwt.strategy.ts @@ -1,7 +1,8 @@ import { Injectable } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; import { User } from "@prisma/client"; -import { ExtractJwt, Strategy } from "passport-jwt"; +import { Request } from "express"; +import { Strategy } from "passport-jwt"; import { ConfigService } from "src/config/config.service"; import { PrismaService } from "src/prisma/prisma.service"; @@ -10,11 +11,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) { constructor(config: ConfigService, private prisma: PrismaService) { config.get("JWT_SECRET"); super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: JwtStrategy.extractJWT, secretOrKey: config.get("JWT_SECRET"), }); } + private static extractJWT(req: Request) { + if (!req.cookies.access_token) return null; + return req.cookies.access_token; + } + async validate(payload: { sub: string }) { const user: User = await this.prisma.user.findUnique({ where: { id: payload.sub }, diff --git a/backend/src/main.ts b/backend/src/main.ts index 20784f3c..10bb3a68 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,6 +1,7 @@ import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common"; import { NestFactory, Reflector } from "@nestjs/core"; import { NestExpressApplication } from "@nestjs/platform-express"; +import * as cookieParser from "cookie-parser"; import * as fs from "fs"; import { AppModule } from "./app.module"; @@ -9,6 +10,7 @@ async function bootstrap() { app.useGlobalPipes(new ValidationPipe({ whitelist: true })); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); + app.use(cookieParser()); app.set("trust proxy", true); await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); diff --git a/frontend/src/components/navBar/ActionAvatar.tsx b/frontend/src/components/navBar/ActionAvatar.tsx index 9af43410..5929112e 100644 --- a/frontend/src/components/navBar/ActionAvatar.tsx +++ b/frontend/src/components/navBar/ActionAvatar.tsx @@ -37,7 +37,7 @@ const ActionAvatar = () => {