Skip to content
This repository was archived by the owner on Sep 8, 2025. It is now read-only.

Commit 28c3eea

Browse files
committed
Add 2FA on login
1 parent c9f2479 commit 28c3eea

4 files changed

Lines changed: 161 additions & 124 deletions

File tree

client/src/views/pages/login/Login.js

Lines changed: 94 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
CInputGroupText,
1616
CFormInput,
1717
CButton,
18-
CButtonGroup,
1918
CTooltip,
2019
CSpinner,
2120
} from '@coreui/react'
@@ -25,6 +24,8 @@ import { useStateContext } from '../../../context/contextProvider'
2524
const Login = () => {
2625
const [username, setUsername] = useState('')
2726
const [password, setPassword] = useState('')
27+
const [code, setCode] = useState('')
28+
const [step, setStep] = useState(1)
2829
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
2930
const [errorMessage, setErrorMessage] = useState(null)
3031
const [isLoading, setIsLoading] = useState(false)
@@ -38,33 +39,35 @@ const Login = () => {
3839

3940
try {
4041
const response = await axios.post('/login', { username, password })
41-
const { token, user } = response.data
4242

43-
if (token && user?.userRole) {
44-
// Store token and user info
45-
sessionStorage.setItem('token', token)
46-
sessionStorage.setItem('user', JSON.stringify(user))
43+
// Proceed to 2FA step
44+
setStep(2)
45+
} catch (err) {
46+
console.error(err)
47+
setErrorMessage(err.response?.data?.message || 'Login failed.')
48+
} finally {
49+
setIsLoading(false)
50+
}
51+
}
4752

48-
// Notify app
49-
window.dispatchEvent(new Event('storage'))
53+
const handle2FAVerification = async (e) => {
54+
e.preventDefault()
55+
setIsLoading(true)
56+
setErrorMessage(null)
5057

51-
// Set global user (if you're using context)
52-
setUser(user)
58+
try {
59+
const response = await axios.post('/verify-2fa', { username, code })
60+
const { token, user } = response.data
5361

54-
// Redirect based on role
55-
if (user.userRole === 'admin') {
56-
navigate('/admindashboard')
57-
} else if (user.userRole === 'user') {
58-
navigate('/')
59-
} else {
60-
setErrorMessage('Unknown role. Please contact support.')
61-
}
62-
} else {
63-
setErrorMessage('Invalid credentials or user role not found.')
64-
}
62+
sessionStorage.setItem('token', token)
63+
sessionStorage.setItem('user', JSON.stringify(user))
64+
window.dispatchEvent(new Event('storage'))
65+
setUser(user)
66+
67+
navigate(user.userRole === 'admin' ? '/admindashboard' : '/')
6568
} catch (err) {
6669
console.error(err)
67-
setErrorMessage('Login failed. Please try again.')
70+
setErrorMessage(err.response?.data?.message || '2FA verification failed.')
6871
} finally {
6972
setIsLoading(false)
7073
}
@@ -88,81 +91,78 @@ const Login = () => {
8891
{errorMessage}
8992
</CAlert>
9093
)}
91-
<CForm onSubmit={handleLogin}>
92-
<h1>Login</h1>
94+
95+
<CForm onSubmit={step === 1 ? handleLogin : handle2FAVerification}>
96+
<h1>{step === 1 ? 'Login' : '2FA Verification'}</h1>
9397
<p className="text-body-secondary">Sign in to your account</p>
94-
<CInputGroup className="mb-3">
95-
<CInputGroupText>
96-
<FontAwesomeIcon icon={faUser} />
97-
</CInputGroupText>
98-
<CFormInput
99-
type="text"
100-
placeholder="Username"
101-
autoComplete="username"
102-
value={username}
103-
onChange={(e) => setUsername(e.target.value)}
104-
required
105-
/>
106-
</CInputGroup>
107-
<CInputGroup className="mb-3">
108-
<CInputGroupText>
109-
<FontAwesomeIcon icon={faLock} />
110-
</CInputGroupText>
111-
<CFormInput
112-
type={isPasswordVisible ? 'text' : 'password'}
113-
placeholder="Password"
114-
autoComplete="current-password"
115-
value={password}
116-
onChange={(e) => setPassword(e.target.value)}
117-
required
118-
/>
119-
<CInputGroupText>
120-
<CTooltip
121-
content={isPasswordVisible ? 'Hide password' : 'Show password'}
122-
placement="top"
123-
>
124-
<span onClick={() => setIsPasswordVisible(!isPasswordVisible)}>
125-
<FontAwesomeIcon icon={isPasswordVisible ? faEyeSlash : faEye} />
126-
</span>
127-
</CTooltip>
128-
</CInputGroupText>
129-
</CInputGroup>
130-
<p>
131-
<small>
132-
By continuing, you agree to our
133-
<a href="/policy" className="text-primary">
134-
{' '}
135-
Privacy Policy{' '}
136-
</a>
137-
and
138-
<a href="/terms" className="text-primary">
139-
{' '}
140-
Terms of Service
141-
</a>
142-
.
143-
</small>
144-
</p>
98+
99+
{step === 1 ? (
100+
<>
101+
<CInputGroup className="mb-3">
102+
<CInputGroupText>
103+
<FontAwesomeIcon icon={faUser} />
104+
</CInputGroupText>
105+
<CFormInput
106+
type="text"
107+
placeholder="Username"
108+
autoComplete="username"
109+
value={username}
110+
onChange={(e) => setUsername(e.target.value)}
111+
required
112+
/>
113+
</CInputGroup>
114+
115+
<CInputGroup className="mb-3">
116+
<CInputGroupText>
117+
<FontAwesomeIcon icon={faLock} />
118+
</CInputGroupText>
119+
<CFormInput
120+
type={isPasswordVisible ? 'text' : 'password'}
121+
placeholder="Password"
122+
autoComplete="current-password"
123+
value={password}
124+
onChange={(e) => setPassword(e.target.value)}
125+
required
126+
/>
127+
<CInputGroupText>
128+
<CTooltip
129+
content={isPasswordVisible ? 'Hide password' : 'Show password'}
130+
placement="top"
131+
>
132+
<span onClick={() => setIsPasswordVisible(!isPasswordVisible)}>
133+
<FontAwesomeIcon icon={isPasswordVisible ? faEyeSlash : faEye} />
134+
</span>
135+
</CTooltip>
136+
</CInputGroupText>
137+
</CInputGroup>
138+
</>
139+
) : (
140+
<CInputGroup className="mb-3">
141+
<CInputGroupText>🔐</CInputGroupText>
142+
<CFormInput
143+
type="text"
144+
placeholder="Enter 2FA Code"
145+
value={code}
146+
onChange={(e) => setCode(e.target.value)}
147+
required
148+
/>
149+
</CInputGroup>
150+
)}
151+
145152
<div className="d-grid mb-3">
146-
<CButtonGroup>
147-
<CButton type="submit" color="primary" className="rounded">
148-
Login
149-
</CButton>
150-
<CButton
151-
color="primary"
152-
className="rounded"
153-
onClick={() => navigate('/signup')}
154-
>
155-
Signup
156-
</CButton>
157-
</CButtonGroup>
153+
<CButton type="submit" color="primary" className="rounded">
154+
{step === 1 ? 'Login' : 'Verify Code'}
155+
</CButton>
158156
</div>
159-
<CButton
160-
color="link"
161-
className="px-0 text-primary"
162-
onClick={() => console.log('Forgot password clicked')}
163-
>
164-
Forgot password?
165-
</CButton>
157+
158+
{step === 1 && (
159+
<p>
160+
{"Don't have an account?"}{' '}
161+
<a href="/signup" className="signup-link">
162+
Signup
163+
</a>
164+
</p>
165+
)}
166166
</CForm>
167167
</CCardBody>
168168
</CCard>

server/index.js

Lines changed: 56 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const socketIo = require('socket.io');
88
const path = require("path");
99
const bcryptjs = require('bcryptjs');
1010
const jwt = require('jsonwebtoken');
11+
const nodemailer = require('nodemailer')
1112
const admin = require("firebase-admin");
1213
const rateLimit = require('express-rate-limit');
1314
const serviceAccount = require("./firebase-service-account.json");
@@ -154,46 +155,71 @@ const getCoordinates = async (address) => {
154155
}
155156
};
156157

158+
const twoFACodes = {}
159+
160+
const transporter = nodemailer.createTransport({
161+
service: 'gmail',
162+
auth: {
163+
user: process.env.MAIL_USER,
164+
pass: process.env.MAIL_PASS,
165+
},
166+
})
157167

158168
//Login
159169
app.post('/login', async (req, res) => {
160-
const { username, password } = req.body;
170+
const { username, password } = req.body
161171

162172
try {
163-
164-
const user = await User.findOne({ username });
173+
const user = await User.findOne({ username })
174+
if (!user) return res.status(404).json({ message: 'User not found' })
175+
176+
const isPasswordValid = await bcryptjs.compare(password, user.password)
177+
if (!isPasswordValid) return res.status(401).json({ message: 'Invalid credentials' })
178+
179+
// Generate a 2FA code
180+
const code = Math.floor(100000 + Math.random() * 900000).toString()
181+
twoFACodes[username] = { code, expires: Date.now() + 5 * 60 * 1000 }
182+
183+
// Send the code via email
184+
await transporter.sendMail({
185+
from: '"Login Verification" <noreply@example.com>',
186+
to: user.email,
187+
subject: 'Your Login 2FA Code',
188+
text: `Your 2FA code is: ${code}. It expires in 5 minutes.`,
189+
})
165190

166-
if (!user) {
167-
return res.status(404).json({ message: 'User not found' });
168-
}
191+
res.json({ requires2FA: true, username })
192+
} catch (error) {
193+
console.error('Error during login:', error)
194+
res.status(500).json({ message: 'Server error' })
195+
}
196+
})
197+
// Verify
198+
app.post('/verify-2fa', async (req, res) => {
199+
const { username, code } = req.body
200+
const entry = twoFACodes[username]
169201

170-
171-
const isPasswordValid = await bcryptjs.compare(password, user.password);
202+
if (!entry || entry.code !== code || Date.now() > entry.expires) {
203+
return res.status(401).json({ message: 'Invalid or expired 2FA code' })
204+
}
172205

173-
if (!isPasswordValid) {
174-
return res.status(401).json({ message: 'Invalid credentials' });
175-
}
206+
delete twoFACodes[username]
176207

177-
178-
const token = jwt.sign(
179-
{ username: user.username, userRole: user.userRole },
180-
process.env.JWT_SECRET,
181-
{ expiresIn: '1h' }
182-
);
208+
const user = await User.findOne({ username })
209+
const token = jwt.sign(
210+
{ username: user.username, userRole: user.userRole },
211+
process.env.JWT_SECRET,
212+
{ expiresIn: '1h' }
213+
)
183214

184-
console.log(user);
185-
res.json({
186-
token,
187-
user: {
188-
username: user.username,
189-
userRole: user.userRole
190-
}
191-
});
192-
} catch (error) {
193-
console.error('Error during login:', error);
194-
res.status(500).json({ message: 'Server error' });
195-
}
196-
});
215+
res.json({
216+
token,
217+
user: {
218+
username: user.username,
219+
userRole: user.userRole,
220+
},
221+
})
222+
})
197223

198224
//Signup
199225
app.post("/signup", async (req, res) => {

server/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"lint": "^1.1.2",
2929
"mongodb": "^6.9.0",
3030
"mongoose": "^8.7.1",
31+
"nodemailer": "^6.10.1",
3132
"pino": "^9.3.2",
3233
"pino-http": "^10.3.0",
3334
"pino-pretty": "^11.2.2",

0 commit comments

Comments
 (0)