feat: use cookies for authentication

This commit is contained in:
Elias Schneider
2023-01-04 11:54:28 +01:00
parent 71658ad39d
commit faea1abcc4
12 changed files with 193 additions and 74 deletions

View File

@@ -23,6 +23,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@@ -42,6 +43,7 @@
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.2.1",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",
@@ -1151,6 +1153,15 @@
"@types/node": "*" "@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": { "node_modules/@types/cookiejar": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz",
@@ -2635,6 +2646,26 @@
"node": ">= 0.6" "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": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -8413,6 +8444,15 @@
"@types/node": "*" "@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": { "@types/cookiejar": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", "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", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz",
"integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" "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": { "cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@@ -28,6 +28,7 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.13.2", "class-validator": "^0.13.2",
"content-disposition": "^0.5.4", "content-disposition": "^0.5.4",
"cookie-parser": "^1.4.6",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"moment": "^2.29.4", "moment": "^2.29.4",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@@ -47,6 +48,7 @@
"@nestjs/schematics": "^9.0.3", "@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1", "@nestjs/testing": "^9.2.1",
"@types/archiver": "^5.3.1", "@types/archiver": "^5.3.1",
"@types/cookie-parser": "^1.4.3",
"@types/cron": "^2.0.0", "@types/cron": "^2.0.0",
"@types/express": "^4.17.14", "@types/express": "^4.17.14",
"@types/mime-types": "^2.1.1", "@types/mime-types": "^2.1.1",

View File

@@ -27,7 +27,8 @@ model User {
} }
model RefreshToken { model RefreshToken {
token String @id @default(uuid()) id String @id @default(uuid())
token String @unique @default(uuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
expiresAt DateTime expiresAt DateTime

View File

@@ -5,10 +5,14 @@ import {
HttpCode, HttpCode,
Patch, Patch,
Post, Post,
Req,
Res,
UnauthorizedException,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler"; import { Throttle } from "@nestjs/throttler";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { Request, Response } from "express";
import { ConfigService } from "src/config/config.service"; import { ConfigService } from "src/config/config.service";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthTotpService } from "./authTotp.service"; import { AuthTotpService } from "./authTotp.service";
@@ -17,7 +21,6 @@ import { AuthRegisterDTO } from "./dto/authRegister.dto";
import { AuthSignInDTO } from "./dto/authSignIn.dto"; import { AuthSignInDTO } from "./dto/authSignIn.dto";
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto"; import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
import { EnableTotpDTO } from "./dto/enableTotp.dto"; import { EnableTotpDTO } from "./dto/enableTotp.dto";
import { RefreshAccessTokenDTO } from "./dto/refreshAccessToken.dto";
import { UpdatePasswordDTO } from "./dto/updatePassword.dto"; import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
import { VerifyTotpDTO } from "./dto/verifyTotp.dto"; import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
import { JwtGuard } from "./guard/jwt.guard"; import { JwtGuard } from "./guard/jwt.guard";
@@ -32,24 +35,59 @@ export class AuthController {
@Throttle(10, 5 * 60) @Throttle(10, 5 * 60)
@Post("signUp") @Post("signUp")
async signUp(@Body() dto: AuthRegisterDTO) { async signUp(
@Body() dto: AuthRegisterDTO,
@Res({ passthrough: true }) response: Response
) {
if (!this.config.get("ALLOW_REGISTRATION")) if (!this.config.get("ALLOW_REGISTRATION"))
throw new ForbiddenException("Registration is not allowed"); 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) @Throttle(10, 5 * 60)
@Post("signIn") @Post("signIn")
@HttpCode(200) @HttpCode(200)
signIn(@Body() dto: AuthSignInDTO) { async signIn(
return this.authService.signIn(dto); @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) @Throttle(10, 5 * 60)
@Post("signIn/totp") @Post("signIn/totp")
@HttpCode(200) @HttpCode(200)
signInTotp(@Body() dto: AuthSignInTotpDTO) { async signInTotp(
return this.authTotpService.signInTotp(dto); @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") @Patch("password")
@@ -60,13 +98,33 @@ export class AuthController {
@Post("token") @Post("token")
@HttpCode(200) @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( const accessToken = await this.authService.refreshAccessToken(
body.refreshToken request.cookies.refresh_token
); );
response.cookie("access_token", accessToken, { httpOnly: true });
return { accessToken }; 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") @Post("totp/enable")
@UseGuards(JwtGuard) @UseGuards(JwtGuard)
async enableTotp(@GetUser() user: User, @Body() body: EnableTotpDTO) { 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 // 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); 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;
}
} }

View File

@@ -34,8 +34,10 @@ export class AuthService {
}, },
}); });
const accessToken = await this.createAccessToken(user); const { refreshToken, refreshTokenId } = await this.createRefreshToken(
const refreshToken = await this.createRefreshToken(user.id); user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} catch (e) { } catch (e) {
@@ -71,8 +73,10 @@ export class AuthService {
return { loginToken }; return { loginToken };
} }
const accessToken = await this.createAccessToken(user); const { refreshToken, refreshTokenId } = await this.createRefreshToken(
const refreshToken = await this.createRefreshToken(user.id); user.id
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }
@@ -89,11 +93,12 @@ export class AuthService {
}); });
} }
async createAccessToken(user: User) { async createAccessToken(user: User, refreshTokenId: string) {
return this.jwtService.sign( return this.jwtService.sign(
{ {
sub: user.id, sub: user.id,
email: user.email, email: user.email,
refreshTokenId,
}, },
{ {
expiresIn: "15min", 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) { async refreshAccessToken(refreshToken: string) {
const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({ const refreshTokenMetaData = await this.prisma.refreshToken.findUnique({
where: { token: refreshToken }, where: { token: refreshToken },
@@ -111,17 +124,18 @@ export class AuthService {
if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date()) if (!refreshTokenMetaData || refreshTokenMetaData.expiresAt < new Date())
throw new UnauthorizedException(); throw new UnauthorizedException();
return this.createAccessToken(refreshTokenMetaData.user); return this.createAccessToken(
refreshTokenMetaData.user,
refreshTokenMetaData.id
);
} }
async createRefreshToken(userId: string) { async createRefreshToken(userId: string) {
const refreshToken = ( const { id, token } = await this.prisma.refreshToken.create({
await this.prisma.refreshToken.create({ data: { userId, expiresAt: moment().add(3, "months").toDate() },
data: { userId, expiresAt: moment().add(3, "months").toDate() }, });
})
).token;
return refreshToken; return { refreshTokenId: id, refreshToken: token };
} }
async createLoginToken(userId: string) { async createLoginToken(userId: string) {

View File

@@ -71,8 +71,12 @@ export class AuthTotpService {
data: { used: true }, data: { used: true },
}); });
const accessToken = await this.authService.createAccessToken(user); const { refreshToken, refreshTokenId } =
const refreshToken = await this.authService.createRefreshToken(user.id); await this.authService.createRefreshToken(user.id);
const accessToken = await this.authService.createAccessToken(
user,
refreshTokenId
);
return { accessToken, refreshToken }; return { accessToken, refreshToken };
} }

View File

@@ -1,6 +0,0 @@
import { IsNotEmpty } from "class-validator";
export class RefreshAccessTokenDTO {
@IsNotEmpty()
refreshToken: string;
}

View File

@@ -1,7 +1,8 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport"; import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client"; 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 { ConfigService } from "src/config/config.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
@@ -10,11 +11,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService, private prisma: PrismaService) { constructor(config: ConfigService, private prisma: PrismaService) {
config.get("JWT_SECRET"); config.get("JWT_SECRET");
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: JwtStrategy.extractJWT,
secretOrKey: config.get("JWT_SECRET"), 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 }) { async validate(payload: { sub: string }) {
const user: User = await this.prisma.user.findUnique({ const user: User = await this.prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },

View File

@@ -1,6 +1,7 @@
import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common"; import { ClassSerializerInterceptor, ValidationPipe } from "@nestjs/common";
import { NestFactory, Reflector } from "@nestjs/core"; import { NestFactory, Reflector } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express"; import { NestExpressApplication } from "@nestjs/platform-express";
import * as cookieParser from "cookie-parser";
import * as fs from "fs"; import * as fs from "fs";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
@@ -9,6 +10,7 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe({ whitelist: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector))); app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.use(cookieParser());
app.set("trust proxy", true); app.set("trust proxy", true);
await fs.promises.mkdir("./data/uploads/_temp", { recursive: true }); await fs.promises.mkdir("./data/uploads/_temp", { recursive: true });

View File

@@ -37,7 +37,7 @@ const ActionAvatar = () => {
<Menu.Item <Menu.Item
onClick={async () => { onClick={async () => {
authService.signOut(); await authService.signOut();
}} }}
icon={<TbDoorExit size={14} />} icon={<TbDoorExit size={14} />}
> >

View File

@@ -1,20 +1,7 @@
import axios, { AxiosError } from "axios"; import axios from "axios";
import { getCookie } from "cookies-next";
const api = axios.create({ const api = axios.create({
baseURL: "/api", baseURL: "/api",
}); });
api.interceptors.request.use(
(config) => {
const accessToken = getCookie("access_token");
if (accessToken) {
config!.headers!.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
}
);
export default api; export default api;

View File

@@ -1,4 +1,4 @@
import { getCookie, setCookie } from "cookies-next"; import { getCookie } from "cookies-next";
import * as jose from "jose"; import * as jose from "jose";
import api from "./api.service"; import api from "./api.service";
@@ -12,11 +12,6 @@ const signIn = async (emailOrUsername: string, password: string) => {
password, password,
}); });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
@@ -37,45 +32,30 @@ const signInTotp = async (
loginToken, loginToken,
}); });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
const signUp = async (email: string, username: string, password: string) => { const signUp = async (email: string, username: string, password: string) => {
const response = await api.post("auth/signUp", { email, username, password }); const response = await api.post("auth/signUp", { email, username, password });
setCookie("access_token", response.data.accessToken);
setCookie("refresh_token", response.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30 * 3,
});
return response; return response;
}; };
const signOut = () => { const signOut = async () => {
setCookie("access_token", null); await api.post("/auth/signOut");
setCookie("refresh_token", null);
window.location.reload(); window.location.reload();
}; };
const refreshAccessToken = async () => { const refreshAccessToken = async () => {
try { try {
const accessToken = getCookie("access_token") as string; const accessToken = getCookie("access_token") as string;
const refreshToken = getCookie("refresh_token");
if ( if (
(accessToken && !accessToken ||
(jose.decodeJwt(accessToken).exp ?? 0) * 1000 < (jose.decodeJwt(accessToken).exp ?? 0) * 1000 < Date.now() + 2 * 60 * 1000
Date.now() + 2 * 60 * 1000) ||
(refreshToken && !accessToken)
) { ) {
const response = await api.post("auth/token", { refreshToken }); await api.post("/auth/token");
setCookie("access_token", response.data.accessToken);
} }
} catch { } catch (e) {
console.info("Refresh token invalid or expired"); console.info("Refresh token invalid or expired");
} }
}; };