Update README.md
[KisSync.git] / src / web / account.js
blob248a75d41c4e6e29dc93c110706a38b21290a93f
1 /**
2 * web/account.js - Webserver details for account management
4 * @author Calvin Montgomery <cyzon@cyzon.us>
5 */
7 var webserver = require("./webserver");
8 var sendPug = require("./pug").sendPug;
9 var Logger = require("../logger");
10 var db = require("../database");
11 var $util = require("../utilities");
12 var Config = require("../config");
13 var session = require("../session");
14 var csrf = require("./csrf");
15 const url = require("url");
16 import crypto from 'crypto';
18 const LOGGER = require('@calzoneman/jsli')('web/accounts');
20 let globalMessageBus;
21 let emailConfig;
22 let emailController;
24 /**
25 * Handles a GET request for /account/edit
27 function handleAccountEditPage(req, res) {
28 sendPug(res, "account-edit", {});
31 function verifyReferrer(req, expected) {
32 const referrer = req.header('referer');
34 if (!referrer) {
35 return true;
38 try {
39 const parsed = url.parse(referrer);
41 if (parsed.pathname !== expected) {
42 LOGGER.warn(
43 'Possible attempted forgery: %s POSTed to %s',
44 referrer,
45 expected
47 return false;
50 return true;
51 } catch (error) {
52 return false;
56 /**
57 * Handles a POST request to edit a user"s account
59 function handleAccountEdit(req, res) {
60 csrf.verify(req);
62 if (!verifyReferrer(req, '/account/edit')) {
63 res.status(403).send('Mismatched referrer');
64 return;
67 var action = req.body.action;
68 switch(action) {
69 case "change_password":
70 handleChangePassword(req, res);
71 break;
72 case "change_email":
73 handleChangeEmail(req, res);
74 break;
75 default:
76 res.sendStatus(400);
77 break;
81 /**
82 * Handles a request to change the user"s password
84 async function handleChangePassword(req, res) {
85 var name = req.body.name;
86 var oldpassword = req.body.oldpassword;
87 var newpassword = req.body.newpassword;
89 if (typeof name !== "string" ||
90 typeof oldpassword !== "string" ||
91 typeof newpassword !== "string") {
92 res.send(400);
93 return;
96 if (newpassword.length === 0) {
97 sendPug(res, "account-edit", {
98 errorMessage: "New password must not be empty"
99 });
100 return;
103 const reqUser = await webserver.authorize(req);
104 if (!reqUser) {
105 sendPug(res, "account-edit", {
106 errorMessage: "You must be logged in to change your password"
108 return;
111 newpassword = newpassword.substring(0, 100);
113 db.users.verifyLogin(name, oldpassword, function (err, _user) {
114 if (err) {
115 sendPug(res, "account-edit", {
116 errorMessage: err
118 return;
121 db.users.setPassword(name, newpassword, function (err, _dbres) {
122 if (err) {
123 sendPug(res, "account-edit", {
124 errorMessage: err
126 return;
129 Logger.eventlog.log("[account] " + req.realIP +
130 " changed password for " + name);
132 db.users.getUser(name, function (err, user) {
133 if (err) {
134 return sendPug(res, "account-edit", {
135 errorMessage: err
139 var expiration = new Date(parseInt(req.signedCookies.auth.split(":")[1]));
140 session.genSession(user, expiration, function (err, auth) {
141 if (err) {
142 return sendPug(res, "account-edit", {
143 errorMessage: err
147 webserver.setAuthCookie(req, res, expiration, auth);
149 sendPug(res, "account-edit", {
150 successMessage: "Password changed."
159 * Handles a request to change the user"s email
161 function handleChangeEmail(req, res) {
162 var name = req.body.name;
163 var password = req.body.password;
164 var email = req.body.email;
166 if (typeof name !== "string" ||
167 typeof password !== "string" ||
168 typeof email !== "string") {
169 res.send(400);
170 return;
173 if (!$util.isValidEmail(email) && email !== "") {
174 sendPug(res, "account-edit", {
175 errorMessage: "Invalid email address"
177 return;
180 db.users.verifyLogin(name, password, function (err, _user) {
181 if (err) {
182 sendPug(res, "account-edit", {
183 errorMessage: err
185 return;
188 db.users.setEmail(name, email, function (err, _dbres) {
189 if (err) {
190 sendPug(res, "account-edit", {
191 errorMessage: err
193 return;
195 Logger.eventlog.log("[account] " + req.realIP +
196 " changed email for " + name +
197 " to " + email);
198 sendPug(res, "account-edit", {
199 successMessage: "Email address changed."
206 * Handles a GET request for /account/channels
208 async function handleAccountChannelPage(req, res) {
209 const user = await webserver.authorize(req);
210 // TODO: error message
211 if (!user) {
212 return sendPug(res, "account-channels", {
213 channels: []
217 db.channels.listUserChannels(user.name, function (err, channels) {
218 sendPug(res, "account-channels", {
219 channels: channels
225 * Handles a POST request to modify a user"s channels
227 function handleAccountChannel(req, res) {
228 csrf.verify(req);
230 if (!verifyReferrer(req, '/account/channels')) {
231 res.status(403).send('Mismatched referrer');
232 return;
235 var action = req.body.action;
236 switch(action) {
237 case "new_channel":
238 handleNewChannel(req, res);
239 break;
240 case "delete_channel":
241 handleDeleteChannel(req, res);
242 break;
243 default:
244 res.send(400);
245 break;
250 * Handles a request to register a new channel
252 async function handleNewChannel(req, res) {
254 var name = req.body.name;
255 if (typeof name !== "string") {
256 res.send(400);
257 return;
260 const user = await webserver.authorize(req);
261 // TODO: error message
262 if (!user) {
263 return sendPug(res, "account-channels", {
264 channels: []
268 db.channels.listUserChannels(user.name, function (err, channels) {
269 if (err) {
270 sendPug(res, "account-channels", {
271 channels: [],
272 newChannelError: err
274 return;
277 if (name.match(Config.get("reserved-names.channels"))) {
278 sendPug(res, "account-channels", {
279 channels: channels,
280 newChannelError: "That channel name is reserved"
282 return;
285 if (channels.length >= Config.get("max-channels-per-user")
286 && user.global_rank < 255) {
287 sendPug(res, "account-channels", {
288 channels: channels,
289 newChannelError: "You are not allowed to register more than " +
290 Config.get("max-channels-per-user") + " channels."
292 return;
295 db.channels.register(name, user.name, function (err, _channel) {
296 if (!err) {
297 Logger.eventlog.log("[channel] " + user.name + "@" +
298 req.realIP +
299 " registered channel " + name);
300 globalMessageBus.emit('ChannelRegistered', {
301 channel: name
304 channels.push({
305 name: name
310 sendPug(res, "account-channels", {
311 channels: channels,
312 newChannelError: err ? err : undefined
319 * Handles a request to delete a new channel
321 async function handleDeleteChannel(req, res) {
322 var name = req.body.name;
323 if (typeof name !== "string") {
324 res.send(400);
325 return;
328 const user = await webserver.authorize(req);
329 // TODO: error
330 if (!user) {
331 return sendPug(res, "account-channels", {
332 channels: [],
337 db.channels.lookup(name, function (err, channel) {
338 if (err) {
339 sendPug(res, "account-channels", {
340 channels: [],
341 deleteChannelError: err
343 return;
346 if ((!channel.owner || channel.owner.toLowerCase() !== user.name.toLowerCase()) && user.global_rank < 255) {
347 db.channels.listUserChannels(user.name, function (err2, channels) {
348 sendPug(res, "account-channels", {
349 channels: err2 ? [] : channels,
350 deleteChannelError: "You do not have permission to delete this channel"
353 return;
356 db.channels.drop(name, function (err) {
357 if (!err) {
358 Logger.eventlog.log("[channel] " + user.name + "@" +
359 req.realIP + " deleted channel " +
360 name);
363 globalMessageBus.emit('ChannelDeleted', {
364 channel: name
367 db.channels.listUserChannels(user.name, function (err2, channels) {
368 sendPug(res, "account-channels", {
369 channels: err2 ? [] : channels,
370 deleteChannelError: err ? err : undefined
378 * Handles a GET request for /account/profile
380 async function handleAccountProfilePage(req, res) {
381 const user = await webserver.authorize(req);
382 // TODO: error message
383 if (!user) {
384 return sendPug(res, "account-profile", {
385 profileImage: "",
386 profileText: ""
390 db.users.getProfile(user.name, function (err, profile) {
391 if (err) {
392 sendPug(res, "account-profile", {
393 profileError: err,
394 profileImage: "",
395 profileText: ""
397 return;
400 sendPug(res, "account-profile", {
401 profileImage: profile.image,
402 profileText: profile.text,
403 profileError: false
408 function validateProfileImage(image, callback) {
409 var prefix = "Invalid URL for profile image: ";
410 var link = image.trim();
411 if (!link) {
412 process.nextTick(callback, null, link);
413 } else {
414 var data = url.parse(link);
415 if (!data.protocol || data.protocol !== 'https:') {
416 process.nextTick(callback,
417 new Error(prefix + " URL must begin with 'https://'"));
418 } else if (!data.host) {
419 process.nextTick(callback,
420 new Error(prefix + "missing hostname"));
421 } else {
422 process.nextTick(callback, null, link);
428 * Handles a POST request to edit a profile
430 async function handleAccountProfile(req, res) {
431 csrf.verify(req);
433 if (!verifyReferrer(req, '/account/profile')) {
434 res.status(403).send('Mismatched referrer');
435 return;
438 const user = await webserver.authorize(req);
439 // TODO: error message
440 if (!user) {
441 return sendPug(res, "account-profile", {
442 profileImage: "",
443 profileText: "",
444 profileError: "You must be logged in to edit your profile",
448 var rawImage = String(req.body.image).substring(0, 255);
449 var text = String(req.body.text).substring(0, 255);
451 validateProfileImage(rawImage, (error, image) => {
452 if (error) {
453 db.users.getProfile(user.name, function (err, profile) {
454 var errorMessage = err || error.message;
455 sendPug(res, "account-profile", {
456 profileImage: profile ? profile.image : "",
457 profileText: profile ? profile.text : "",
458 profileError: errorMessage
461 return;
464 db.users.setProfile(user.name, { image: image, text: text }, function (err) {
465 if (err) {
466 sendPug(res, "account-profile", {
467 profileImage: "",
468 profileText: "",
469 profileError: err
471 return;
474 globalMessageBus.emit('UserProfileChanged', {
475 user: user.name,
476 profile: {
477 image,
478 text
482 sendPug(res, "account-profile", {
483 profileImage: image,
484 profileText: text,
485 profileError: false
492 * Handles a GET request for /account/passwordreset
494 function handlePasswordResetPage(req, res) {
495 sendPug(res, "account-passwordreset", {
496 reset: false,
497 resetEmail: "",
498 resetErr: false
503 * Handles a POST request to reset a user's password
505 function handlePasswordReset(req, res) {
506 csrf.verify(req);
508 if (!verifyReferrer(req, '/account/passwordreset')) {
509 res.status(403).send('Mismatched referrer');
510 return;
513 var name = req.body.name,
514 email = req.body.email;
516 if (typeof name !== "string" || typeof email !== "string") {
517 res.send(400);
518 return;
521 if (!$util.isValidUserName(name)) {
522 sendPug(res, "account-passwordreset", {
523 reset: false,
524 resetEmail: "",
525 resetErr: "Invalid username '" + name + "'"
527 return;
530 db.users.getEmail(name, function (err, actualEmail) {
531 if (err) {
532 sendPug(res, "account-passwordreset", {
533 reset: false,
534 resetEmail: "",
535 resetErr: err
537 return;
540 if (actualEmail === '') {
541 sendPug(res, "account-passwordreset", {
542 reset: false,
543 resetEmail: "",
544 resetErr: `Username ${name} cannot be recovered because it ` +
545 "doesn't have an email address associated with it."
547 return;
548 } else if (actualEmail.toLowerCase() !== email.trim().toLowerCase()) {
549 sendPug(res, "account-passwordreset", {
550 reset: false,
551 resetEmail: "",
552 resetErr: "Provided email does not match the email address on record for " + name
554 return;
557 crypto.randomBytes(20, (err, bytes) => {
558 if (err) {
559 LOGGER.error(
560 'Could not generate random bytes for password reset: %s',
561 err.stack
563 sendPug(res, "account-passwordreset", {
564 reset: false,
565 resetEmail: email,
566 resetErr: "Internal error when generating password reset"
568 return;
571 var hash = bytes.toString('hex');
572 // 24-hour expiration
573 var expire = Date.now() + 86400000;
574 var ip = req.realIP;
576 db.addPasswordReset({
577 ip: ip,
578 name: name,
579 email: actualEmail,
580 hash: hash,
581 expire: expire
582 }, function (err, _dbres) {
583 if (err) {
584 sendPug(res, "account-passwordreset", {
585 reset: false,
586 resetEmail: "",
587 resetErr: err
589 return;
592 Logger.eventlog.log("[account] " + ip + " requested password recovery for " +
593 name + " <" + email + ">");
595 if (!emailConfig.getPasswordReset().isEnabled()) {
596 sendPug(res, "account-passwordreset", {
597 reset: false,
598 resetEmail: email,
599 resetErr: "This server does not have mail support enabled. Please " +
600 "contact an administrator for assistance."
602 return;
605 const baseUrl = `${req.realProtocol}://${req.header("host")}`;
607 emailController.sendPasswordReset({
608 username: name,
609 address: email,
610 url: `${baseUrl}/account/passwordrecover/${hash}`
611 }).then(_result => {
612 sendPug(res, "account-passwordreset", {
613 reset: true,
614 resetEmail: email,
615 resetErr: false
617 }).catch(error => {
618 LOGGER.error("Sending password reset email failed: %s", error);
619 sendPug(res, "account-passwordreset", {
620 reset: false,
621 resetEmail: email,
622 resetErr: "Sending reset email failed. Please contact an " +
623 "administrator for assistance."
632 * Handles a request for /account/passwordrecover/<hash>
634 function handleGetPasswordRecover(req, res) {
635 var hash = req.params.hash;
636 if (typeof hash !== "string") {
637 res.send(400);
638 return;
641 db.lookupPasswordReset(hash, function (err, row) {
642 if (err) {
643 sendPug(res, "account-passwordrecover", {
644 recovered: false,
645 recoverErr: err
647 return;
650 if (Date.now() >= row.expire) {
651 sendPug(res, "account-passwordrecover", {
652 recovered: false,
653 recoverErr: "This password recovery link has expired. Password " +
654 "recovery links are valid only for 24 hours after " +
655 "submission."
657 return;
660 sendPug(res, "account-passwordrecover", {
661 confirm: true,
662 recovered: false
668 * Handles a POST request for /account/passwordrecover/<hash>
670 function handlePostPasswordRecover(req, res) {
671 var hash = req.params.hash;
672 if (typeof hash !== "string") {
673 res.send(400);
674 return;
677 var ip = req.realIP;
679 db.lookupPasswordReset(hash, function (err, row) {
680 if (err) {
681 sendPug(res, "account-passwordrecover", {
682 recovered: false,
683 recoverErr: err
685 return;
688 if (Date.now() >= row.expire) {
689 sendPug(res, "account-passwordrecover", {
690 recovered: false,
691 recoverErr: "This password recovery link has expired. Password " +
692 "recovery links are valid only for 24 hours after " +
693 "submission."
695 return;
698 var newpw = "";
699 const avail = "abcdefgihkmnpqrstuvwxyz0123456789";
700 for (var i = 0; i < 10; i++) {
701 newpw += avail[Math.floor(Math.random() * avail.length)];
703 db.users.setPassword(row.name, newpw, function (err) {
704 if (err) {
705 sendPug(res, "account-passwordrecover", {
706 recovered: false,
707 recoverErr: "Database error. Please contact an administrator if " +
708 "this persists."
711 return;
714 db.deletePasswordReset(hash);
715 Logger.eventlog.log("[account] " + ip + " recovered password for " + row.name);
717 sendPug(res, "account-passwordrecover", {
718 recovered: true,
719 recoverPw: newpw
725 module.exports = {
727 * Initialize the module
729 init: function (app, _globalMessageBus, _emailConfig, _emailController) {
730 globalMessageBus = _globalMessageBus;
731 emailConfig = _emailConfig;
732 emailController = _emailController;
734 app.get("/account/edit", handleAccountEditPage);
735 app.post("/account/edit", handleAccountEdit);
736 app.get("/account/channels", handleAccountChannelPage);
737 app.post("/account/channels", handleAccountChannel);
738 app.get("/account/profile", handleAccountProfilePage);
739 app.post("/account/profile", handleAccountProfile);
740 app.get("/account/passwordreset", handlePasswordResetPage);
741 app.post("/account/passwordreset", handlePasswordReset);
742 app.get("/account/passwordrecover/:hash", handleGetPasswordRecover);
743 app.post("/account/passwordrecover/:hash", handlePostPasswordRecover);
744 app.get("/account", function (req, res) {
745 res.redirect("/login");