Skip to content

Commit f652fa9

Browse files
mohit038-zztobiasdiez
andauthoredJun 13, 2021
Mutations for forgot password (#121)
* add-mutation * fix-bugs * mutation-update * add comments * bug-fix * add change and forgot password page * clean-up * Update-resetPassword-mutation * update mutation * add-redis-service * redis client * another-try * try * try v2 * Try with localhost? * error-fix * commit changes * fix-test * jest-test * fix-yarn-file * fix-jest-test * Remove new ts-loader * Update .github/workflows/ci.yml Co-authored-by: Tobias Diez <code@tobiasdiez.com> * change-mutation * resolve-conflict * update-nodemailer * update-mutation * update-nodemailer * update-params * Remove Redis config to fix tests * try without TLS * Enable TLS on Azure only Co-authored-by: Tobias Diez <code@tobiasdiez.com>
1 parent f0a536f commit f652fa9

15 files changed

+348
-20
lines changed
 

‎.github/workflows/ci.yml

+12
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ jobs:
3535
ports:
3636
- 5432:5432
3737

38+
redis:
39+
image: redis
40+
# Set health checks to wait until redis has started
41+
options: >-
42+
--health-cmd "redis-cli ping"
43+
--health-interval 10s
44+
--health-timeout 5s
45+
--health-retries 5
46+
ports:
47+
# Maps port 6379 on service container to port 6380 on host
48+
- 6380:6379
49+
3850
env:
3951
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/jabref?schema=public
4052

‎api/tsyringe.config.ts

+10-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 'reflect-metadata'
22
import { PrismaClient } from '@prisma/client'
3-
import { RedisClient, createClient } from 'redis'
3+
import { RedisClient, createClient, ClientOpts } from 'redis'
44

55
import { container, instanceCachingFactory } from 'tsyringe'
66
import { config } from '../config'
@@ -10,10 +10,14 @@ container.register<PrismaClient>(PrismaClient, {
1010
})
1111

1212
container.register<RedisClient>(RedisClient, {
13-
useFactory: instanceCachingFactory<RedisClient>(() =>
14-
createClient({
13+
useFactory: instanceCachingFactory<RedisClient>(() => {
14+
const redisConfig: ClientOpts = {
1515
...config.redis,
16-
tls: { servername: config.redis.host },
17-
})
18-
),
16+
}
17+
// Azure needs a TLS connection to Redis
18+
if (process.env.NODE_ENV === 'production') {
19+
redisConfig.tls = { servername: config.redis.host }
20+
}
21+
return createClient(redisConfig)
22+
}),
1923
})

‎api/user/auth.service.ts

+59-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import { promisify } from 'util'
12
import { PrismaClient, User } from '@prisma/client'
23
import * as bcrypt from 'bcryptjs'
34
import { injectable } from 'tsyringe'
5+
import { v4 as generateToken } from 'uuid'
6+
import { RedisClient } from 'redis'
7+
import { sendEmail } from '../utils/sendEmail'
8+
import { resetPasswordTemplate } from '../utils/resetPasswordTemplate'
49

510
export interface AuthenticationMessage {
611
message?: string
@@ -13,7 +18,7 @@ export interface AuthenticateReturn {
1318

1419
@injectable()
1520
export class AuthService {
16-
constructor(private prisma: PrismaClient) {}
21+
constructor(private prisma: PrismaClient, private redisClient: RedisClient) {}
1722

1823
async validateUser(email: string, password: string): Promise<User | null> {
1924
const user = await this.prisma.user.findUnique({
@@ -33,6 +38,27 @@ export class AuthService {
3338
}
3439
}
3540

41+
async resetPassword(email: string): Promise<boolean> {
42+
const user = await this.getUserByEmail(email)
43+
if (!user) {
44+
return true
45+
}
46+
const PREFIX = process.env.PREFIX || 'forgot-password'
47+
const key = PREFIX + user.id
48+
const token = generateToken()
49+
const hashedToken = await this.hashString(token)
50+
this.redisClient.set(key, hashedToken, 'ex', 1000 * 60 * 60 * 24) // VALID FOR ONE DAY
51+
await sendEmail(email, resetPasswordTemplate(user.id, token))
52+
return true
53+
}
54+
55+
async hashString(password: string): Promise<string> {
56+
// Hash password before saving in database
57+
// We use salted hashing to prevent rainbow table attacks
58+
const salt = await bcrypt.genSalt()
59+
return await bcrypt.hash(password, salt)
60+
}
61+
3662
async getUserById(id: string): Promise<User | null> {
3763
return await this.prisma.user.findUnique({
3864
where: {
@@ -59,11 +85,7 @@ export class AuthService {
5985
if (userWithEmailAlreadyExists) {
6086
throw new Error(`User with email '${email}' already exists.`)
6187
}
62-
63-
// Hash password before saving in database
64-
// We use salted hashing to prevent rainbow table attacks
65-
const salt = await bcrypt.genSalt()
66-
const hashedPassword = await bcrypt.hash(password, salt)
88+
const hashedPassword = await this.hashString(password)
6789

6890
return await this.prisma.user.create({
6991
data: {
@@ -72,4 +94,35 @@ export class AuthService {
7294
},
7395
})
7496
}
97+
98+
async updatePassword(
99+
token: string,
100+
id: string,
101+
newPassword: string
102+
): Promise<User | null> {
103+
if (newPassword.length <= 6) {
104+
return null
105+
}
106+
const PREFIX = process.env.PREFIX || 'forgot-password'
107+
const key = PREFIX + id
108+
const getAsync = promisify(this.redisClient.get).bind(this.redisClient)
109+
const hashedToken = await getAsync(key)
110+
if (!hashedToken) {
111+
return null
112+
}
113+
const checkToken = await bcrypt.compare(token, hashedToken)
114+
if (checkToken) {
115+
this.redisClient.del(key)
116+
const hashedPassword = await this.hashString(newPassword)
117+
return await this.prisma.user.update({
118+
where: {
119+
id,
120+
},
121+
data: {
122+
password: hashedPassword,
123+
},
124+
})
125+
}
126+
return null
127+
}
75128
}

‎api/user/passport-initializer.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import connectRedis from 'connect-redis'
22
import { Express } from 'express-serve-static-core'
33
import session from 'express-session'
4-
import { RedisClient } from 'redis'
54
import passport from 'passport'
5+
import { RedisClient } from 'redis'
66
import { injectable } from 'tsyringe'
77
import { AuthService } from './auth.service'
88
import LocalStrategy from './local.strategy'

‎api/user/resolvers.ts

+19
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ export class Resolvers {
5959
return true
6060
}
6161

62+
async forgotPassword(email: string): Promise<boolean> {
63+
return await this.authService.resetPassword(email)
64+
}
65+
66+
async changePassword(
67+
token: string,
68+
id: string,
69+
newPassword: string
70+
): Promise<User | null> {
71+
const user = await this.authService.updatePassword(token, id, newPassword)
72+
return user
73+
}
74+
6275
resolvers(): AllResolvers {
6376
return {
6477
Query: {
@@ -82,6 +95,12 @@ export class Resolvers {
8295
signup: (_root, { email, password }, context) => {
8396
return this.signup(email, password, context)
8497
},
98+
forgotPassword: (_root, { email }, _context) => {
99+
return this.forgotPassword(email)
100+
},
101+
changePassword: (_root, { token, id, newPassword }, _context) => {
102+
return this.changePassword(token, id, newPassword)
103+
},
85104
},
86105

87106
User: {

‎api/user/schema.graphql

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ extend type Mutation {
1414
logout: Boolean
1515
login(email: String!, password: String!): User
1616
signup(email: String!, password: String!): User
17+
forgotPassword(email: String!): Boolean
18+
changePassword(token: String!, id:String!, newPassword: String!): User
19+
1720
}
1821

1922
type User {

‎api/utils/resetPasswordTemplate.ts

+67
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
export function resetPasswordTemplate(id: string, token: string): string {
2+
return `
3+
<!doctype html>
4+
<html lang="en-US">
5+
6+
<head>
7+
<meta content="text/html; charset=utf-8" http-equiv="Content-Type" />
8+
<title>Reset Password Email</title>
9+
<meta name="description" content="Reset Password Email Template.">
10+
<style type="text/css">
11+
a:hover {text-decoration: underline !important;}
12+
</style>
13+
</head>
14+
15+
<body marginheight="0" topmargin="0" marginwidth="0" style="margin: 0px; background-color: #f2f3f8;" leftmargin="0">
16+
<table cellspacing="0" border="0" cellpadding="0" width="100%" bgcolor="#f2f3f8" font-family: 'Open Sans', sans-serif;">
17+
<tr>
18+
<td>
19+
<table style="background-color: #f2f3f8; max-width:670px; margin:0 auto;" width="100%" border="0"
20+
align="center" cellpadding="0" cellspacing="0">
21+
<tr>
22+
<td style="height:80px;">&nbsp;</td>
23+
</tr>
24+
<tr>
25+
<td style="height:20px;">&nbsp;</td>
26+
</tr>
27+
<tr>
28+
<td>
29+
<table width="95%" border="0" align="center" cellpadding="0" cellspacing="0"
30+
style="max-width:670px;background:#fff; border-radius:3px; text-align:center;-webkit-box-shadow:0 6px 18px 0 rgba(0,0,0,.06);-moz-box-shadow:0 6px 18px 0 rgba(0,0,0,.06);box-shadow:0 6px 18px 0 rgba(0,0,0,.06);">
31+
<tr>
32+
<td style="height:40px;">&nbsp;</td>
33+
</tr>
34+
<tr>
35+
<td style="padding:0 35px;">
36+
<h1 style="color:#1e1e2d; font-weight:500; margin:0;font-size:32px;font-family:'Rubik',sans-serif;">We received a request to reset your password</h1>
37+
<span
38+
style="display:inline-block; vertical-align:middle; margin:29px 0 26px; border-bottom:1px solid #cecece; width:100px;"></span>
39+
<p style="color:#455056; font-size:15px;line-height:24px; margin:0;">
40+
A unique link to reset your
41+
password has been generated for you. To reset your password, click the
42+
following link and follow the instructions.
43+
</p>
44+
<a href="http://localhost:3000/change-password?id=${id}&token=${token}"
45+
style="background:black;text-decoration:none !important; font-weight:500; margin-top:35px; color:#fff;text-transform:uppercase; font-size:14px;padding:10px 24px;display:inline-block;">Reset
46+
Password</a>
47+
</td>
48+
</tr>
49+
<tr>
50+
<td style="height:40px;">&nbsp;</td>
51+
</tr>
52+
</table>
53+
</td>
54+
<tr>
55+
<td style="height:20px;">&nbsp;</td>
56+
</tr>
57+
<tr>
58+
<td style="height:80px;">&nbsp;</td>
59+
</tr>
60+
</table>
61+
</td>
62+
</tr>
63+
</table>
64+
</body>
65+
66+
</html>`
67+
}

‎api/utils/sendEmail.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import nodemailer from 'nodemailer'
2+
3+
export async function sendEmail(to: string, html: string): Promise<void> {
4+
const testAccount = await nodemailer.createTestAccount()
5+
const transporter = nodemailer.createTransport({
6+
host: 'smtp.ethereal.email',
7+
port: 587,
8+
secure: false, // true for 465, false for other ports
9+
auth: {
10+
user: testAccount.user, // generated ethereal user
11+
pass: testAccount.pass, // generated ethereal password
12+
},
13+
})
14+
await transporter.sendMail({
15+
from: '"Jabref" <Jabref@example.com>', // sender address
16+
to, // list of receivers
17+
subject: 'Reset your password', // Subject line
18+
html, // plain text body
19+
})
20+
}

‎dump.rdb

92 Bytes
Binary file not shown.

‎package.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"graphql-passport": "^0.6.3",
6666
"graphql-type-datetime": "^0.2.4",
6767
"lodash": "^4.17.21",
68+
"nodemailer": "^6.6.0",
6869
"nuxt": "^2.15.6",
6970
"passport": "^0.4.1",
7071
"pinia": "^0.5.2",
@@ -101,8 +102,10 @@
101102
"@types/express": "^4.17.12",
102103
"@types/express-session": "^1.17.3",
103104
"@types/jest": "^26.0.23",
104-
"@types/lodash": "^4.14.170",
105+
"@types/lodash": "^4.14.169",
106+
"@types/nodemailer": "^6.4.1",
105107
"@types/passport": "^1.0.6",
108+
"@types/uuid": "^8.3.0",
106109
"@vue/compiler-sfc": "^3.1.1",
107110
"@vue/test-utils": "^1.2.0",
108111
"babel-jest": "^26.6.3",

‎pages/change-password/_token.vue

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<template>
2+
<div>
3+
<Portal to="header">
4+
<Logo class="mx-auto h-20 w-auto" />
5+
<h2 class="mt-8 text-center text-5xl font-extrabold text-gray-900">
6+
Change Password
7+
</h2>
8+
</Portal>
9+
<form @submit.prevent="changePassword">
10+
<div class="space-y-5">
11+
<t-input-group label="New Password" variant="important">
12+
<PasswordInput v-model="password" />
13+
</t-input-group>
14+
<t-input-group label="Confirm Password" variant="important">
15+
<PasswordInput v-model="repeatPassword" />
16+
</t-input-group>
17+
</div>
18+
<div class="py-2">
19+
<t-button class="w-full" type="submit">Change Password</t-button>
20+
</div>
21+
</form>
22+
</div>
23+
</template>
24+
25+
<script>
26+
import {
27+
defineComponent,
28+
useRouter,
29+
useRoute,
30+
computed,
31+
} from '@nuxtjs/composition-api'
32+
import { ref } from '@vue/composition-api'
33+
import { gql } from 'graphql-tag'
34+
import { useChangePasswordMutation } from '../../apollo/graphql'
35+
export default defineComponent({
36+
name: 'ChangePassword',
37+
layout: 'bare',
38+
setup() {
39+
const password = ref('')
40+
const repeatPassword = ref('')
41+
gql`
42+
mutation ChangePassword(
43+
$token: String!
44+
$id: String!
45+
$newPassword: String!
46+
) {
47+
changePassword(token: $token, id: $id, newPassword: $newPassword) {
48+
id
49+
}
50+
}
51+
`
52+
const route = useRoute()
53+
const token = computed(() => route.value.query.token)
54+
const id = computed(() => route.value.query.id)
55+
const {
56+
mutate: changePassword,
57+
onDone,
58+
error,
59+
} = useChangePasswordMutation(() => ({
60+
variables: {
61+
token: token.value,
62+
id: id.value,
63+
newPassword: password.value,
64+
},
65+
}))
66+
const router = useRouter()
67+
onDone(() => {
68+
router.push('../user/login')
69+
})
70+
return { password, error, changePassword, repeatPassword }
71+
},
72+
})
73+
</script>

‎pages/user/forgot-password.vue

+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<template>
2+
<div>
3+
<Portal to="header">
4+
<Logo class="mx-auto h-20 w-auto" />
5+
<h2 class="mt-8 text-center text-5xl font-extrabold text-gray-900">
6+
Reset Password
7+
</h2>
8+
</Portal>
9+
<div v-if="called">
10+
<h2>Email Sent</h2>
11+
<p>
12+
An email with instructions on how to reset your password has been sent
13+
to {{ email }}.
14+
</p>
15+
</div>
16+
<form v-else @submit.prevent="forgotPassword">
17+
<div class="space-y-5">
18+
<t-input-group label="Email address" variant="important">
19+
<t-input v-model="email" v-focus />
20+
</t-input-group>
21+
<div class="py-2">
22+
<t-button class="w-full" type="submit">Submit</t-button>
23+
</div>
24+
</div>
25+
</form>
26+
</div>
27+
</template>
28+
<script lang="ts">
29+
import { defineComponent } from '@nuxtjs/composition-api'
30+
import { ref } from '@vue/composition-api'
31+
import { gql } from 'graphql-tag'
32+
import { useForgotPasswordMutation } from '../../apollo/graphql'
33+
34+
export default defineComponent({
35+
name: 'ForgotPassword',
36+
layout: 'bare',
37+
setup() {
38+
const email = ref('')
39+
gql`
40+
mutation ForgotPassword($email: String!) {
41+
forgotPassword(email: $email)
42+
}
43+
`
44+
const {
45+
mutate: forgotPassword,
46+
called,
47+
error,
48+
} = useForgotPasswordMutation(() => ({
49+
variables: {
50+
email: email.value,
51+
},
52+
}))
53+
return { email, error, called, forgotPassword }
54+
},
55+
})
56+
</script>

‎pages/user/login.vue

+2-5
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,9 @@
3636
</div>
3737

3838
<div class="text-sm">
39-
<a
40-
href="#"
41-
class="font-medium text-indigo-600 hover:text-indigo-500"
39+
<t-nuxtlink to="./forgot-password"
40+
>Forgot your password?</t-nuxtlink
4241
>
43-
Forgot your password?
44-
</a>
4542
</div>
4643
</div>
4744

‎test/jest.setup.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import 'reflect-metadata'
22
import '~/api/tsyringe.config'
33
import { GraphQLResponse } from 'apollo-server-types'
4+
import { container } from 'tsyringe'
5+
import { RedisClient } from 'redis'
46

57
// Minimize snapshot of GraphQL responses (no extensions and http field)
68
expect.addSnapshotSerializer({
@@ -20,3 +22,5 @@ expect.addSnapshotSerializer({
2022
})
2123
},
2224
})
25+
26+
afterAll(() => container.resolve(RedisClient).quit())

‎yarn.lock

+18-1
Original file line numberDiff line numberDiff line change
@@ -2975,7 +2975,7 @@
29752975
resolved "https://registry.yarnpkg.com/@types/less/-/less-3.0.2.tgz#2761d477678c8374cb9897666871662eb1d1115e"
29762976
integrity sha512-62vfe65cMSzYaWmpmhqCMMNl0khen89w57mByPi1OseGfcV/LV03fO8YVrNj7rFQsRWNJo650WWyh6m7p8vZmA==
29772977

2978-
"@types/lodash@^4.14.170":
2978+
"@types/lodash@^4.14.169":
29792979
version "4.14.170"
29802980
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.170.tgz#0d67711d4bf7f4ca5147e9091b847479b87925d6"
29812981
integrity sha512-bpcvu/MKHHeYX+qeEN8GE7DIravODWdACVA1ctevD8CN24RhPZIKMn9ntfAsrvLfSX3cR5RrBKAbYm9bGs0A+Q==
@@ -3020,6 +3020,13 @@
30203020
resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.56.tgz#010c9e047c3ff09ddcd11cbb6cf5912725cdc2b3"
30213021
integrity sha512-LuAa6t1t0Bfw4CuSR0UITsm1hP17YL+u82kfHGrHUWdhlBtH7sa7jGY5z7glGaIj/WDYDkRtgGd+KCjCzxBW1w==
30223022

3023+
"@types/nodemailer@^6.4.1":
3024+
version "6.4.1"
3025+
resolved "https://registry.yarnpkg.com/@types/nodemailer/-/nodemailer-6.4.1.tgz#31f96f4410632f781d3613bd1f4293649e423f75"
3026+
integrity sha512-8081UY/0XTTDpuGqCnDc8IY+Q3DSg604wB3dBH0CaZlj4nZWHWuxtZ3NRZ9c9WUrz1Vfm6wioAUnqL3bsh49uQ==
3027+
dependencies:
3028+
"@types/node" "*"
3029+
30233030
"@types/normalize-package-data@^2.4.0":
30243031
version "2.4.0"
30253032
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
@@ -3148,6 +3155,11 @@
31483155
resolved "https://registry.yarnpkg.com/@types/ungap__global-this/-/ungap__global-this-0.3.1.tgz#18ce9f657da556037a29d50604335614ce703f4c"
31493156
integrity sha512-+/DsiV4CxXl6ZWefwHZDXSe1Slitz21tom38qPCaG0DYCS1NnDPIQDTKcmQ/tvK/edJUKkmuIDBJbmKDiB0r/g==
31503157

3158+
"@types/uuid@^8.3.0":
3159+
version "8.3.0"
3160+
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
3161+
integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
3162+
31513163
"@types/webpack-bundle-analyzer@3.9.3":
31523164
version "3.9.3"
31533165
resolved "https://registry.yarnpkg.com/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.9.3.tgz#3a12025eb5d86069c30b47a157e62c0aca6e39a1"
@@ -11067,6 +11079,11 @@ node-res@^5.0.1:
1106711079
on-finished "^2.3.0"
1106811080
vary "^1.1.2"
1106911081

11082+
nodemailer@^6.6.0:
11083+
version "6.6.0"
11084+
resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.0.tgz#ed47bb572b48d9d0dca3913fdc156203f438f427"
11085+
integrity sha512-ikSMDU1nZqpo2WUPE0wTTw/NGGImTkwpJKDIFPZT+YvvR9Sj+ze5wzu95JHkBMglQLoG2ITxU21WukCC/XsFkg==
11086+
1107011087
nopt@^5.0.0:
1107111088
version "5.0.0"
1107211089
resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"

0 commit comments

Comments
 (0)
Please sign in to comment.