mirror of
https://github.com/swissmakers/swiss-datashare.git
synced 2026-04-11 10:27:01 +02:00
feat!: reset password with email
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "ResetPasswordToken" (
|
||||
"token" TEXT NOT NULL PRIMARY KEY,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "ResetPasswordToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- Disable TOTP as secret isn't encrypted anymore
|
||||
UPDATE User SET totpEnabled=false, totpSecret=null, totpVerified=false WHERE totpSecret IS NOT NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ResetPasswordToken_userId_key" ON "ResetPasswordToken"("userId");
|
||||
@@ -22,9 +22,10 @@ model User {
|
||||
loginTokens LoginToken[]
|
||||
reverseShares ReverseShare[]
|
||||
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
totpEnabled Boolean @default(false)
|
||||
totpVerified Boolean @default(false)
|
||||
totpSecret String?
|
||||
resetPasswordToken ResetPasswordToken?
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
@@ -49,6 +50,16 @@ model LoginToken {
|
||||
used Boolean @default(false)
|
||||
}
|
||||
|
||||
model ResetPasswordToken {
|
||||
token String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
expiresAt DateTime
|
||||
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Share {
|
||||
id String @id @default(uuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -21,15 +21,6 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
category: "internal",
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
order: 0,
|
||||
key: "TOTP_SECRET",
|
||||
description: "A 16 byte random string used to generate TOTP secrets",
|
||||
type: "string",
|
||||
value: crypto.randomBytes(16).toString("base64"),
|
||||
category: "internal",
|
||||
locked: true,
|
||||
},
|
||||
{
|
||||
order: 1,
|
||||
key: "APP_URL",
|
||||
@@ -89,6 +80,15 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
},
|
||||
{
|
||||
order: 7,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
type: "string",
|
||||
value: "Files shared with you",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent to the share recipients. {creator} and {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
@@ -98,16 +98,16 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 8,
|
||||
key: "SHARE_RECEPIENTS_EMAIL_SUBJECT",
|
||||
order: 9,
|
||||
key: "REVERSE_SHARE_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent to the share recipients.",
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
type: "string",
|
||||
value: "Files shared with you",
|
||||
value: "Reverse share link used",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 9,
|
||||
order: 10,
|
||||
key: "REVERSE_SHARE_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent when someone created a share with your reverse share link. {shareUrl} will be replaced with the creator's name and the share URL.",
|
||||
@@ -117,16 +117,27 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 10,
|
||||
key: "REVERSE_SHARE_EMAIL_SUBJECT",
|
||||
order: 11,
|
||||
key: "RESET_PASSWORD_EMAIL_SUBJECT",
|
||||
description:
|
||||
"Subject of the email which gets sent when someone created a share with your reverse share link.",
|
||||
"Subject of the email which gets sent when a user requests a password reset.",
|
||||
type: "string",
|
||||
value: "Reverse share link used",
|
||||
value: "Pingvin Share password reset",
|
||||
category: "email",
|
||||
},
|
||||
{
|
||||
order: 11,
|
||||
order: 12,
|
||||
key: "RESET_PASSWORD_EMAIL_MESSAGE",
|
||||
description:
|
||||
"Message which gets sent when a user requests a password reset. {url} will be replaced with the reset password URL.",
|
||||
type: "text",
|
||||
value:
|
||||
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
|
||||
category: "email",
|
||||
},
|
||||
|
||||
{
|
||||
order: 13,
|
||||
key: "SMTP_ENABLED",
|
||||
description:
|
||||
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
|
||||
@@ -136,7 +147,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
secret: false,
|
||||
},
|
||||
{
|
||||
order: 12,
|
||||
order: 14,
|
||||
key: "SMTP_HOST",
|
||||
description: "Host of the SMTP server",
|
||||
type: "string",
|
||||
@@ -144,7 +155,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 13,
|
||||
order: 15,
|
||||
key: "SMTP_PORT",
|
||||
description: "Port of the SMTP server",
|
||||
type: "number",
|
||||
@@ -152,7 +163,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 14,
|
||||
order: 16,
|
||||
key: "SMTP_EMAIL",
|
||||
description: "Email address which the emails get sent from",
|
||||
type: "string",
|
||||
@@ -160,7 +171,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 15,
|
||||
order: 17,
|
||||
key: "SMTP_USERNAME",
|
||||
description: "Username of the SMTP server",
|
||||
type: "string",
|
||||
@@ -168,7 +179,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
|
||||
category: "smtp",
|
||||
},
|
||||
{
|
||||
order: 16,
|
||||
order: 18,
|
||||
key: "SMTP_PASSWORD",
|
||||
description: "Password of the SMTP server",
|
||||
type: "string",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
HttpCode,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
@@ -21,6 +22,7 @@ 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 { ResetPasswordDTO } from "./dto/resetPassword.dto";
|
||||
import { TokenDTO } from "./dto/token.dto";
|
||||
import { UpdatePasswordDTO } from "./dto/updatePassword.dto";
|
||||
import { VerifyTotpDTO } from "./dto/verifyTotp.dto";
|
||||
@@ -34,8 +36,8 @@ export class AuthController {
|
||||
private config: ConfigService
|
||||
) {}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signUp")
|
||||
@Throttle(10, 5 * 60)
|
||||
async signUp(
|
||||
@Body() dto: AuthRegisterDTO,
|
||||
@Res({ passthrough: true }) response: Response
|
||||
@@ -54,8 +56,8 @@ export class AuthController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn")
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signIn(
|
||||
@Body() dto: AuthSignInDTO,
|
||||
@@ -74,8 +76,8 @@ export class AuthController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Throttle(10, 5 * 60)
|
||||
@Post("signIn/totp")
|
||||
@Throttle(10, 5 * 60)
|
||||
@HttpCode(200)
|
||||
async signInTotp(
|
||||
@Body() dto: AuthSignInTotpDTO,
|
||||
@@ -92,6 +94,20 @@ export class AuthController {
|
||||
return new TokenDTO().from(result);
|
||||
}
|
||||
|
||||
@Post("resetPassword/:email")
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async requestResetPassword(@Param("email") email: string) {
|
||||
return await this.authService.requestResetPassword(email);
|
||||
}
|
||||
|
||||
@Post("resetPassword")
|
||||
@Throttle(5, 5 * 60)
|
||||
@HttpCode(204)
|
||||
async resetPassword(@Body() dto: ResetPasswordDTO) {
|
||||
return await this.authService.resetPassword(dto.token, dto.password);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(JwtGuard)
|
||||
async updatePassword(
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { EmailModule } from "src/email/email.module";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthTotpService } from "./authTotp.service";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [JwtModule.register({})],
|
||||
imports: [JwtModule.register({}), EmailModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthTotpService, JwtStrategy],
|
||||
exports: [AuthService],
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
|
||||
import * as argon from "argon2";
|
||||
import * as moment from "moment";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { EmailService } from "src/email/email.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthRegisterDTO } from "./dto/authRegister.dto";
|
||||
import { AuthSignInDTO } from "./dto/authSignIn.dto";
|
||||
@@ -19,7 +20,8 @@ export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private config: ConfigService
|
||||
private config: ConfigService,
|
||||
private emailService: EmailService
|
||||
) {}
|
||||
|
||||
async signUp(dto: AuthRegisterDTO) {
|
||||
@@ -87,6 +89,50 @@ export class AuthService {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async requestResetPassword(email: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { email },
|
||||
include: { resetPasswordToken: true },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("User not found");
|
||||
|
||||
// Delete old reset password token
|
||||
if (user.resetPasswordToken) {
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token: user.resetPasswordToken.token },
|
||||
});
|
||||
}
|
||||
|
||||
const { token } = await this.prisma.resetPasswordToken.create({
|
||||
data: {
|
||||
expiresAt: moment().add(1, "hour").toDate(),
|
||||
user: { connect: { id: user.id } },
|
||||
},
|
||||
});
|
||||
|
||||
await this.emailService.sendResetPasswordEmail(user.email, token);
|
||||
}
|
||||
|
||||
async resetPassword(token: string, newPassword: string) {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { resetPasswordToken: { token } },
|
||||
});
|
||||
|
||||
if (!user) throw new BadRequestException("Token invalid or expired");
|
||||
|
||||
const newPasswordHash = await argon.hash(newPassword);
|
||||
|
||||
await this.prisma.resetPasswordToken.delete({
|
||||
where: { token },
|
||||
});
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: newPasswordHash },
|
||||
});
|
||||
}
|
||||
|
||||
async updatePassword(user: User, oldPassword: string, newPassword: string) {
|
||||
if (!(await argon.verify(user.password, oldPassword)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
|
||||
@@ -6,10 +6,8 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import * as argon from "argon2";
|
||||
import * as crypto from "crypto";
|
||||
import { authenticator, totp } from "otplib";
|
||||
import * as qrcode from "qrcode-svg";
|
||||
import { ConfigService } from "src/config/config.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@@ -17,7 +15,6 @@ import { AuthSignInTotpDTO } from "./dto/authSignInTotp.dto";
|
||||
@Injectable()
|
||||
export class AuthTotpService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
private authService: AuthService
|
||||
) {}
|
||||
@@ -57,9 +54,7 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, dto.password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (dto.totp !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
@@ -81,41 +76,6 @@ export class AuthTotpService {
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
encryptTotpSecret(totpSecret: string, password: string) {
|
||||
let iv = this.config.get("TOTP_SECRET");
|
||||
iv = Buffer.from(iv, "base64");
|
||||
const key = crypto
|
||||
.createHash("sha256")
|
||||
.update(String(password))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
|
||||
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
||||
|
||||
let encrypted = cipher.update(totpSecret);
|
||||
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
return encrypted.toString("base64");
|
||||
}
|
||||
|
||||
decryptTotpSecret(encryptedTotpSecret: string, password: string) {
|
||||
let iv = this.config.get("TOTP_SECRET");
|
||||
iv = Buffer.from(iv, "base64");
|
||||
const key = crypto
|
||||
.createHash("sha256")
|
||||
.update(String(password))
|
||||
.digest("base64")
|
||||
.substr(0, 32);
|
||||
|
||||
const encryptedText = Buffer.from(encryptedTotpSecret, "base64");
|
||||
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
||||
let decrypted = decipher.update(encryptedText);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
|
||||
return decrypted.toString();
|
||||
}
|
||||
|
||||
async enableTotp(user: User, password: string) {
|
||||
if (!(await argon.verify(user.password, password)))
|
||||
throw new ForbiddenException("Invalid password");
|
||||
@@ -132,7 +92,6 @@ export class AuthTotpService {
|
||||
|
||||
// TODO: Maybe make the issuer configurable with env vars?
|
||||
const secret = authenticator.generateSecret();
|
||||
const encryptedSecret = this.encryptTotpSecret(secret, password);
|
||||
|
||||
const otpURL = totp.keyuri(
|
||||
user.username || user.email,
|
||||
@@ -144,7 +103,7 @@ export class AuthTotpService {
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
totpEnabled: true,
|
||||
totpSecret: encryptedSecret,
|
||||
totpSecret: secret,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -177,9 +136,7 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is not in progress");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
@@ -208,9 +165,7 @@ export class AuthTotpService {
|
||||
throw new BadRequestException("TOTP is not enabled");
|
||||
}
|
||||
|
||||
const decryptedSecret = this.decryptTotpSecret(totpSecret, password);
|
||||
|
||||
const expected = authenticator.generate(decryptedSecret);
|
||||
const expected = authenticator.generate(totpSecret);
|
||||
|
||||
if (code !== expected) {
|
||||
throw new BadRequestException("Invalid code");
|
||||
|
||||
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
8
backend/src/auth/dto/resetPassword.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
import { UserDTO } from "src/user/dto/user.dto";
|
||||
|
||||
export class ResetPasswordDTO extends PickType(UserDTO, ["password"]) {
|
||||
@IsString()
|
||||
token: string;
|
||||
}
|
||||
@@ -58,6 +58,21 @@ export class EmailService {
|
||||
});
|
||||
}
|
||||
|
||||
async sendResetPasswordEmail(recipientEmail: string, token: string) {
|
||||
const resetPasswordUrl = `${this.config.get(
|
||||
"APP_URL"
|
||||
)}/auth/resetPassword/${token}`;
|
||||
|
||||
await this.getTransporter().sendMail({
|
||||
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
|
||||
to: recipientEmail,
|
||||
subject: this.config.get("RESET_PASSWORD_EMAIL_SUBJECT"),
|
||||
text: this.config
|
||||
.get("RESET_PASSWORD_EMAIL_MESSAGE")
|
||||
.replaceAll("{url}", resetPasswordUrl),
|
||||
});
|
||||
}
|
||||
|
||||
async sendTestMail(recipientEmail: string) {
|
||||
try {
|
||||
await this.getTransporter().sendMail({
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as argon from "argon2";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { CreateUserDTO } from "./dto/createUser.dto";
|
||||
import { UpdateUserDto } from "./dto/updateUser.dto";
|
||||
import { UserDTO } from "./dto/user.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UserSevice {
|
||||
|
||||
Reference in New Issue
Block a user