Skip to content

Commit 7f141b4

Browse files
committed
Admins choose their own passwords: invite links, forgot password, change password
1 parent e817fd8 commit 7f141b4

11 files changed

Lines changed: 464 additions & 41 deletions

File tree

‎backend/middleware/activity.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ function describe(section, method, part, body = {}, last = '') {
6767
return null
6868
case 'admins':
6969
if (method === 'POST' && part === 'reset-2fa') return ['security', 'reset two-step login for']
70-
if (method === 'POST') return ['create', 'added the team member']
70+
if (method === 'POST' && part === 'send-link') return ['email', 'sent a set-password link to']
71+
if (method === 'POST' && !part) return ['create', 'added the team member']
7172
if (method === 'DELETE') return ['delete', 'deleted the account of']
7273
if ((body.state || body.district) && !body.role) return ['role', 'changed the area of']
7374
if (body.role) return ['role', `changed the role to ${ROLE_NAMES[body.role] || 'another role'} for`]

‎backend/models/Admin.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ const adminSchema = new mongoose.Schema(
1919
twoFactorPending: { type: String, select: false },
2020
twoFactorLastStep: { type: Number, default: 0 },
2121
seenDonationsAt: Date,
22+
// false while a new team member has not chosen their own password yet (they were sent a link)
23+
passwordSet: { type: Boolean, default: true },
24+
// A one-time link to choose a password (new account, or a forgotten password). Only a scrambled copy is kept.
25+
setupTokenHash: { type: String, select: false, index: true, sparse: true },
26+
setupTokenExpires: { type: Date, select: false },
27+
setupLinkAt: { type: Date, select: false }, // when the last link was made (stops a flood of emails)
2228
},
2329
{ timestamps: true }
2430
)

‎backend/routes/admins.js‎

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ROLES, can } from '../config/roles.js'
77
import { STATE_CODES } from '../config/states.js'
88
import { canonicalDistrict } from '../config/districts.js'
99
import LoginLock from '../models/LoginLock.js'
10+
import { makeLink, emailLink, INVITE_HOURS, RESET_HOURS } from '../services/passwordLinks.js'
1011

1112
// Team & roles. Super admins manage everyone. A State admin manages only the District admins of their own state.
1213
const router = Router()
@@ -15,8 +16,18 @@ router.use(protect, requirePermission('team'))
1516
const view = (a) => ({
1617
id: a._id, name: a.name, email: a.email, role: a.role, active: a.active !== false, createdAt: a.createdAt,
1718
twoFactorEnabled: a.twoFactorEnabled === true, state: a.state || null, district: a.district || null,
19+
passwordSet: a.passwordSet !== false, // false: invited, has not chosen a password yet
1820
})
1921

22+
// Emails a set-password link. Also gives the link itself, so it can be sent on WhatsApp if the email does not arrive.
23+
async function sendLink(admin, kind) {
24+
const hours = kind === 'invite' ? INVITE_HOURS : RESET_HOURS * 24 // a link sent by the team works for a day
25+
const url = await makeLink(admin._id, hours)
26+
let emailed = false
27+
try { emailed = await emailLink(admin, url, kind, hours) } catch (err) { console.error('Password link email failed:', err.message) }
28+
return { link: url, emailed, hours }
29+
}
30+
2031
// A State admin needs a state; a District admin needs a state and one of its districts; every other role has neither
2132
const NEED_STATE = 'Choose the state this person will look after'
2233
const NEED_DISTRICT = 'Choose the district this District admin will look after'
@@ -57,16 +68,23 @@ router.post('/', async (req, res) => {
5768
const state = limited(req) ? req.admin.state : req.body?.state
5869
if (!name || !String(name).trim()) return res.status(400).json({ message: 'Enter a name' })
5970
if (!/^\S+@\S+\.\S+$/.test(email || '')) return res.status(400).json({ message: 'Enter a valid email address' })
60-
if (!password || String(password).length < 8) {
71+
// No password: the new member gets a link by email and chooses their own (nobody else ever knows it).
72+
// A password typed here still works, for when email is not available.
73+
const invite = password === undefined || password === null || password === ''
74+
if (!invite && String(password).length < 8) {
6175
return res.status(400).json({ message: 'Password must be at least 8 characters' })
6276
}
6377
if (!ROLES[role]) return res.status(400).json({ message: 'Choose a role' })
6478
const where = area(role, state, req.body?.district)
6579
if (where.problem) return res.status(400).json({ message: where.problem })
6680

6781
try {
68-
const admin = await Admin.create({ name: String(name).trim(), email, password, role, state: where.state, district: where.district })
69-
res.status(201).json(view(admin))
82+
const admin = await Admin.create({
83+
name: String(name).trim(), email, role, state: where.state, district: where.district,
84+
// Until they choose one, the account has a long random password that nobody knows
85+
password: invite ? crypto.randomBytes(32).toString('hex') : password, passwordSet: !invite,
86+
})
87+
res.status(201).json({ ...view(admin), ...(invite ? await sendLink(admin, 'invite') : {}) })
7088
} catch (err) {
7189
if (err.code === 11000) return res.status(409).json({ message: 'An account with this email already exists' })
7290
throw err
@@ -118,6 +136,15 @@ router.patch('/:id', ownPasswordTries, async (req, res) => {
118136
res.json(view(target))
119137
})
120138

139+
// A new set-password link for someone in the team: a new member whose first link ran out, or someone who forgot their password
140+
router.post('/:id/send-link', async (req, res) => {
141+
const target = await Admin.findById(req.params.id).catch(() => null)
142+
if (!target || !mine(req, target)) return res.status(404).json({ message: 'Not found' })
143+
if (target._id.equals(req.admin._id)) return res.status(400).json({ message: 'Change your own password on the Security page' })
144+
if (target.active === false) return res.status(400).json({ message: 'Enable this account first' })
145+
res.json({ ...view(target), ...(await sendLink(target, target.passwordSet === false ? 'invite' : 'reset')) })
146+
})
147+
121148
router.post('/:id/reset-2fa', async (req, res) => {
122149
const target = await Admin.findById(req.params.id).catch(() => null)
123150
if (!target || !mine(req, target)) return res.status(404).json({ message: 'Not found' })

‎backend/routes/auth.js‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { recordLogin } from '../services/loginlog.js'
1313
import bcrypt from 'bcryptjs'
1414
import LoginLock from '../models/LoginLock.js'
1515
import KnownDevice from '../models/KnownDevice.js'
16+
import { makeLink, adminForToken, usePasswordLink, emailLink, RESET_HOURS } from '../services/passwordLinks.js'
1617

1718
const router = Router()
1819

@@ -216,4 +217,59 @@ router.post('/2fa/disable', limiter(10), protect, async (req, res) => {
216217
res.json({ ok: true })
217218
})
218219

220+
// ----- Passwords -----
221+
222+
// Change your own password (every admin, from the Security page): needs the current one
223+
router.post('/password', limiter(10), protect, async (req, res) => {
224+
const { currentPassword, newPassword } = req.body || {}
225+
if (String(newPassword || '').length < 8) return res.status(400).json({ message: 'The new password must be at least 8 characters' })
226+
if (String(newPassword).length > 200) return res.status(400).json({ message: 'The new password is too long' })
227+
const admin = await Admin.findById(req.admin._id).select('+password')
228+
if (!(await admin.matchPassword(String(currentPassword || '')))) return res.status(400).json({ message: 'Your current password is not correct' })
229+
if (await admin.matchPassword(String(newPassword))) return res.status(400).json({ message: 'The new password must be different from the current one' })
230+
admin.password = String(newPassword)
231+
admin.passwordSet = true
232+
await admin.save() // the session in use stays: with one session per account, it is this one
233+
recordLogin(req, { admin, success: true, reason: 'changed their own password' })
234+
res.json({ ok: true })
235+
})
236+
237+
// "Forgot password?" on the login page: emails a link to choose a new password.
238+
// The answer is the same whether or not the email belongs to an admin, and comes at once (the email is sent
239+
// afterwards), so this cannot be used to find out who the admins are.
240+
router.post('/forgot', limiter(5), async (req, res) => {
241+
const email = String(req.body?.email || '').trim().toLowerCase().slice(0, 200)
242+
res.json({ ok: true })
243+
try {
244+
if (!/^\S+@\S+\.\S+$/.test(email)) return
245+
const admin = await Admin.findOne({ email }).select('+setupLinkAt')
246+
if (!admin || admin.active === false) return
247+
if (admin.setupLinkAt && Date.now() - admin.setupLinkAt < 2 * 60e3) return // one email every 2 minutes at most
248+
const url = await makeLink(admin._id, RESET_HOURS)
249+
await emailLink(admin, url, 'reset')
250+
recordLogin(req, { admin, success: false, reason: 'asked for a "forgot password" link' })
251+
} catch (err) {
252+
console.error('Forgot-password email failed:', err.message)
253+
}
254+
})
255+
256+
// The set-password page checks its link first, to greet the person by name
257+
router.post('/set-password/check', limiter(20), async (req, res) => {
258+
const admin = await adminForToken(req.body?.token)
259+
if (!admin) return res.status(410).json({ message: 'This link has expired or was already used. Ask for a new one.' })
260+
res.json({ name: admin.name, email: admin.email, isNew: admin.passwordSet === false })
261+
})
262+
263+
// Saves the password chosen through the link. Two-step login (if on) is still asked for at the next login.
264+
router.post('/set-password', limiter(20), async (req, res) => {
265+
const password = String(req.body?.password || '')
266+
if (password.length < 8) return res.status(400).json({ message: 'The password must be at least 8 characters' })
267+
if (password.length > 200) return res.status(400).json({ message: 'The password is too long' })
268+
const admin = await adminForToken(req.body?.token)
269+
if (!admin) return res.status(410).json({ message: 'This link has expired or was already used. Ask for a new one.' })
270+
await usePasswordLink(admin, password)
271+
recordLogin(req, { admin, success: true, reason: 'chose a password with an emailed link' })
272+
res.json({ ok: true, email: admin.email })
273+
})
274+
219275
export default router

‎backend/services/passwordLinks.js‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import crypto from 'crypto'
2+
import Admin from '../models/Admin.js'
3+
import LoginLock from '../models/LoginLock.js'
4+
import { sendMail } from './mailer.js'
5+
import { esc, button } from './emailLayout.js'
6+
import { ORG } from '../config/org.js'
7+
8+
// One-time links to choose a password: for a new team member (instead of the super admin making up a password
9+
// and passing it on), and for an admin who forgot theirs. The link holds a long random code; only a scrambled
10+
// copy (sha256) is saved, so even someone who reads the database cannot use it. It works once, for a limited time.
11+
12+
export const INVITE_HOURS = 72 // a new team member has 3 days to open their link
13+
export const RESET_HOURS = 1 // a "forgot password" link works for 1 hour
14+
15+
export const hashToken = (token) => crypto.createHash('sha256').update(String(token)).digest('hex')
16+
const site = () => (process.env.CLIENT_URL || '').trim().replace(/\/+$/, '').replace('://www.', '://')
17+
18+
// A new link for this admin (any older link stops working). Gives the address to open.
19+
export async function makeLink(adminId, hours) {
20+
const token = crypto.randomBytes(32).toString('hex')
21+
await Admin.updateOne({ _id: adminId }, {
22+
$set: { setupTokenHash: hashToken(token), setupTokenExpires: new Date(Date.now() + hours * 3600e3), setupLinkAt: new Date() },
23+
})
24+
return `${site()}/admin/set-password?t=${token}`
25+
}
26+
27+
// The admin a link belongs to, if it is still valid
28+
export async function adminForToken(token) {
29+
if (!/^[a-f0-9]{64}$/.test(String(token || ''))) return null
30+
const admin = await Admin.findOne({ setupTokenHash: hashToken(token), setupTokenExpires: { $gt: new Date() } })
31+
.select('+password +setupTokenHash +setupTokenExpires')
32+
return admin && admin.active !== false ? admin : null
33+
}
34+
35+
// Saves the chosen password and uses up the link. Logs the account out everywhere and clears wrong-try locks.
36+
export async function usePasswordLink(admin, password) {
37+
admin.password = password
38+
admin.passwordSet = true
39+
admin.sessionId = crypto.randomUUID()
40+
admin.setupTokenHash = undefined
41+
admin.setupTokenExpires = undefined
42+
await admin.save()
43+
await LoginLock.deleteMany({ $or: [{ _id: { $regex: `^${admin._id}:` } }, { _id: `acct:${admin._id}` }] })
44+
}
45+
46+
// Emails the link. true when the mail server took it, false when email is not set up (throws if sending failed).
47+
export function emailLink(admin, url, kind, hours = kind === 'invite' ? INVITE_HOURS : RESET_HOURS) {
48+
const invite = kind === 'invite'
49+
const valid = hours >= 24 ? `${hours / 24} days` : `${hours} hour${hours === 1 ? '' : 's'}`
50+
const subject = invite ? `You have been added to the ${ORG.name} admin team` : `Choose a new password for ${ORG.name}`
51+
const intro = invite
52+
? `You have been added to the ${ORG.name} admin team. Choose your own password to start. Nobody else knows it, not even the person who added you.`
53+
: 'Someone (hopefully you) asked to choose a new password for your admin account.'
54+
const outro = invite ? '' : 'If you did not ask for this, ignore this email: your password stays as it is.'
55+
return sendMail({
56+
to: admin.email,
57+
subject,
58+
text: `Jai Bhim ${admin.name},\n\n${intro}\n\nChoose your password here (the link works for ${valid}, once):\n${url}\n\nYour login email: ${admin.email}\n\n${outro}\n\n— ${ORG.name}`.replace(/\n{3,}/g, '\n\n'),
59+
html: `<p>जय भीम <b>${esc(admin.name)}</b>,</p>
60+
<p>${esc(intro)}</p>
61+
${button(invite ? 'Choose my password / अपना पासवर्ड चुनें' : 'Choose a new password / नया पासवर्ड चुनें', url)}
62+
<p style="color:#555;font-size:13px">The link works for ${valid}, once. Your login email: <b>${esc(admin.email)}</b></p>
63+
${outro ? `<p style="color:#555;font-size:13px">${esc(outro)}</p>` : ''}`,
64+
})
65+
}

‎frontend/src/App.jsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const Donate = page(() => import('./pages/Donate.jsx'))
4444
const Receipt = page(() => import('./pages/Receipt.jsx'))
4545
const Card = page(() => import('./pages/Card.jsx'))
4646
const Login = page(() => import('./pages/admin/Login.jsx'))
47+
const SetPassword = page(() => import('./pages/admin/SetPassword.jsx'))
4748
const AdminLayout = page(() => import('./pages/admin/AdminLayout.jsx'))
4849
const Overview = page(() => import('./pages/admin/Overview.jsx'))
4950
const Content = page(() => import('./pages/admin/Content.jsx'))
@@ -132,6 +133,7 @@ export default function App() {
132133
<Route path="/unsubscribe/:token" element={<Unsubscribe />} />
133134

134135
<Route path="/admin/login" element={<Login />} />
136+
<Route path="/admin/set-password" element={<SetPassword />} />
135137
<Route path="/admin" element={<ProtectedRoute><SessionWatch /><AdminLayout /></ProtectedRoute>}>
136138
<Route index element={<Overview />} />
137139
<Route path="news" element={<Perm perm="content"><Content key="news" kind="news" /></Perm>} />

‎frontend/src/pages/admin/Login.jsx‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,23 @@ export default function Login() {
1818
const [busy, setBusy] = useState(false)
1919
const [show, setShow] = useState(false)
2020
const [notice] = useState(() => { try { return sessionStorage.getItem('loginNotice') || '' } catch { return '' } })
21+
const [forgot, setForgot] = useState(false) // "Forgot password?": ask for a link by email
22+
const [forgotSent, setForgotSent] = useState(false)
23+
24+
async function askLink(e) {
25+
e.preventDefault()
26+
setBusy(true)
27+
setError('')
28+
try {
29+
await api.post('/auth/forgot', { email: form.email })
30+
setForgotSent(true)
31+
} catch (err) {
32+
setError(err.message)
33+
} finally {
34+
setBusy(false)
35+
}
36+
}
37+
const backToLogin = () => { setForgot(false); setForgotSent(false); setError('') }
2138

2239
async function submit(e) {
2340
e.preventDefault()
@@ -64,8 +81,35 @@ export default function Login() {
6481
</div>
6582
</div>
6683

67-
{notice && <p role="status" className="mb-3 rounded-xl border border-gold/50 bg-gold/10 px-3 py-2 text-sm text-navy">{notice}</p>}
84+
{notice && !forgot && <p role="status" className="mb-3 rounded-xl border border-gold/50 bg-gold/10 px-3 py-2 text-sm text-navy">{notice}</p>}
6885

86+
{forgot ? (
87+
forgotSent ? (
88+
<div role="status" className="flex flex-col gap-3">
89+
<p className="rounded-xl border border-green-200 bg-green-50 p-3 text-green-900">
90+
📧 If <b>{form.email}</b> belongs to an admin account, we have sent it a link to choose a new password. It works for 1 hour.
91+
</p>
92+
<p className="text-sm text-navy/70">No email after a few minutes? Check the spam folder, or ask a super admin to send you a link from the Team page.</p>
93+
<button type="button" onClick={backToLogin} className="btn w-full rounded-xl border-2 border-brand text-brand hover:bg-mist">← Back to login</button>
94+
</div>
95+
) : (
96+
<form onSubmit={askLink} className="flex flex-col gap-3">
97+
<p className="text-navy/80">Enter your login email. We will send you a link to choose a new password.</p>
98+
<label className="flex flex-col gap-1 font-semibold">Email
99+
<div className="relative">
100+
<Icon name="mail" size={18} className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-navy/65" />
101+
<input type="email" required autoFocus autoComplete="username" value={form.email} placeholder="you@example.com"
102+
onChange={(e) => setForm({ ...form, email: e.target.value })} className="input pl-11" />
103+
</div>
104+
</label>
105+
{error && <p role="alert" className="border border-red-300 bg-red-50 p-3 text-red-900">{error}</p>}
106+
<button disabled={busy} className="btn w-full gap-2 rounded-xl bg-brand text-white shadow-md transition hover:bg-navy disabled:opacity-60">
107+
{busy ? 'Please wait...' : <>Send me a link <Icon name="mail" size={18} /></>}
108+
</button>
109+
<button type="button" onClick={backToLogin} className="text-brand underline">Back to login</button>
110+
</form>
111+
)
112+
) : (
69113
<form onSubmit={submit} className="flex flex-col gap-3">
70114
{challenge ? (
71115
<label className="flex flex-col gap-1 font-semibold">
@@ -104,7 +148,13 @@ export default function Login() {
104148
Back
105149
</button>
106150
)}
151+
{!challenge && (
152+
<button type="button" onClick={() => { setForgot(true); setError('') }} className="text-sm font-semibold text-brand underline">
153+
Forgot password?
154+
</button>
155+
)}
107156
</form>
157+
)}
108158
<Link to="/" className="mt-3 flex items-center justify-center gap-1 text-sm font-semibold text-navy/65 hover:text-brand">← Back to the website</Link>
109159
</div>
110160
</div>

0 commit comments

Comments
 (0)