1
0

Authentication working

Signed-off-by: James Ketrenos <james_eikona@ketrenos.com>
This commit is contained in:
James Ketrenos 2022-04-06 17:33:18 -07:00
parent b6a62c7de2
commit 74f1f092ec
26 changed files with 557 additions and 130 deletions

1
.gitignore vendored
View File

@ -1,2 +1,3 @@
db/*.db
node_modules node_modules
*.swp *.swp

BIN
client/favicon.xcf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -16,6 +16,8 @@ import { GlobalContext } from "./GlobalContext.js";
import SignIn from "./SignIn.js"; import SignIn from "./SignIn.js";
import SignUp from "./SignUp.js"; import SignUp from "./SignUp.js";
import Group from "./Group.js"; import Group from "./Group.js";
import VerifyEmail from "./VerifyEmail.js";
import Dashboard from "./Dashboard.js";
import { base } from "./Common.js"; import { base } from "./Common.js";
@ -46,23 +48,21 @@ const App = () => {
<Routes> <Routes>
<Route path="/signin" element={<SignIn />}/> <Route path="/signin" element={<SignIn />}/>
<Route path="/signup" element={<SignUp />}/> <Route path="/signup" element={<SignUp />}/>
<Route path="/user/verify-email/:token" element={<VerifyEmail />}/>
<Route path="/password" element={ <Route path="/password" element={
<Paper>Not implemented... yet.</Paper> <Paper>Not implemented... yet.</Paper>
}/> }/>
<Route path="/:group" element={<Group />}/> <Route path="/:group" element={<Group />}/>
<Route path="/" element={ { user && user.mailVerified &&
<Paper style={{ <Route path="/" element={<Dashboard />}/>
flexDirection: 'column', }
display: 'flex' { user && !user.mailVerified &&
}}> <Route path="/" element={
<div style={{ fontWeight: 'bold' }}>Goodtimes</div> <Paper style={{padding: "0.5rem"}}>You need to verify your email via the link sent to {user.email}.</Paper>}/>
<div>The eventual new site for the legacy Beer Tuesday... coming soon... }
</div> { !user &&
{ user && <div> <Route path="/" element={<SignIn />} />
Logged in as {user.email} }
</div> }
</Paper>
}/>
</Routes> </Routes>
</Container> </Container>
</GlobalContext.Provider> </GlobalContext.Provider>

4
client/src/Dashboard.css Normal file
View File

@ -0,0 +1,4 @@
.Dashboard {
text-align: center;
}

78
client/src/Dashboard.js Normal file
View File

@ -0,0 +1,78 @@
import React, { useState, useEffect, useContext } from "react";
import Paper from '@mui/material/Paper';
import Moment from 'react-moment';
import {
useParams,
useNavigate
} from "react-router-dom";
import './Dashboard.css';
import { GlobalContext } from "./GlobalContext.js";
import { base } from "./Common.js";
function Dashboard() {
const { csrfToken, user, setUser } = useContext(GlobalContext);
const [ groups, setGroups ] = useState([]);
const [ error, setError ] = useState(null);
useEffect(() => {
if (!user || !csrfToken) {
return;
}
const effect = async () => {
const res = await window.fetch(
`${base}/api/v1/groups`, {
method: 'POST',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken
}
});
const data = await res.json();
if (res.status >= 400) {
setError(data.message ? data.message : res.statusText);
return;
}
if (!data) {
return;
}
setGroups(data);
}
effect();
}, [user, setGroups, csrfToken ]);
return (
<Paper className="Dashboard">
<GlobalContext.Provider value={{user, setUser}}>
<Paper style={{
flexDirection: 'column',
display: 'flex',
width: '100%'
}}>
{ groups.map((group) => {
return <div key={group.id} style={{
flexDirection: 'column',
display: 'flex',
alignItems: 'flex-start',
padding: '0.5rem'
}}>
<div key={group.id} style={{
fontWeight: 'bold'
}}>{group.name}</div>
{ group.nextEvent &&
<div>Next event <Moment fromNow date={group.nextEvent} /> on <Moment format={'MMMM Do YYYY, h: mm: ss a'} date={group.nextEvent}/>.</div>
}
</div>;
}) }
</Paper>
</GlobalContext.Provider>
</Paper>
);
}
export default Dashboard;

View File

@ -1,4 +1,4 @@
import React, { useContext, useEffect, useState } from 'react'; import React, { useContext, useEffect, useState, useCallback } from 'react';
import AppBar from '@mui/material/AppBar'; import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar'; import Toolbar from '@mui/material/Toolbar';
@ -17,30 +17,13 @@ import {
import Gravatar from 'react-gravatar' import Gravatar from 'react-gravatar'
import { GlobalContext } from "./GlobalContext.js"; import { GlobalContext } from "./GlobalContext.js";
import { base } from "./Common.js";
const GoodTimesBar = () => { const GoodTimesBar = () => {
const { user } = useContext(GlobalContext); const { csrfToken, user, setUser } = useContext(GlobalContext);
const [ settings, setSettings ] = useState([]); const [ settings, setSettings ] = useState([]);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => {
if (user) {
setSettings(['Profile', 'Account', 'Dashboard', 'Logout']
.map((setting) => (
<MenuItem key={setting} onClick={handleCloseUserMenu}>
<Typography textAlign="center">{setting}</Typography>
</MenuItem>
)));
} else {
setSettings(['Login']
.map((setting) => (
<MenuItem key={setting} onClick={handleCloseUserMenu}>
<Typography textAlign="center">{setting}</Typography>
</MenuItem>
)));
}
}, [user, setSettings]);
const [anchorElNav, setAnchorElNav] = React.useState(null); const [anchorElNav, setAnchorElNav] = React.useState(null);
const [anchorElUser, setAnchorElUser] = React.useState(null); const [anchorElUser, setAnchorElUser] = React.useState(null);
@ -51,6 +34,37 @@ const GoodTimesBar = () => {
setAnchorElUser(event.currentTarget); setAnchorElUser(event.currentTarget);
}; };
const handleUserMenu = useCallback(async (event, menu) => {
switch (menu) {
case 'Sign Out': {
const res = await window.fetch(
`${base}/api/v1/users/signout`, {
method: 'POST',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken
}
});
const data = await res.json();
if (res.status >= 400) {
return;
}
setUser(null);
setAnchorElUser(null);
navigate('/');
break;
}
case 'Dashboard':
setAnchorElUser(null);
navigate('/');
break;
}
console.log(event);
}, [csrfToken, setUser]);
const handleCloseNavMenu = () => { const handleCloseNavMenu = () => {
setAnchorElNav(null); setAnchorElNav(null);
}; };
@ -59,6 +73,21 @@ const GoodTimesBar = () => {
setAnchorElUser(null); setAnchorElUser(null);
}; };
console.log(`goodtimes-bar - user - `, user);
useEffect(() => {
if (user) {
setSettings([/*'Profile', 'Account',*/ 'Dashboard', 'Sign Out']
.map((setting) => (
<MenuItem key={setting} onClick={(e) => handleUserMenu(e, setting)}>
<Typography textAlign="center">{setting}</Typography>
</MenuItem>
)));
} else {
setSettings([]);
}
}, [user, setSettings]);
return ( return (
<AppBar position="static"> <AppBar position="static">
<Container maxWidth="xl"> <Container maxWidth="xl">
@ -111,14 +140,15 @@ const GoodTimesBar = () => {
> >
GOODTIMES GOODTIMES
</Typography> </Typography>
<Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}> <Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
</Box> </Box>
<Box sx={{ flexGrow: 0 }}> <Box sx={{ flexGrow: 0 }}>
{ !user && <> { !user && <>
<Tooltip title="Login"> <Tooltip title="Sign In">
<Button onClick={() => navigate("/signin")} color="inherit"> <Button onClick={() => navigate("/signin")} color="inherit">
Login Sign In
</Button> </Button>
</Tooltip> </Tooltip>
</> </>

3
client/src/Group.css Normal file
View File

@ -0,0 +1,3 @@
.Group {
text-align: center;
}

View File

@ -1,22 +1,55 @@
import React, { useState } from "react"; import React, { useState, useEffect, useContext } from "react";
import Paper from '@mui/material/Paper';
import { import {
useParams useParams,
useNavigate
} from "react-router-dom"; } from "react-router-dom";
import './Group.css'; import './Group.css';
import { GlobalContext } from "./GlobalContext.js"; import { GlobalContext } from "./GlobalContext.js";
import Paper from '@mui/material/Paper'; import { base } from "./Common.js";
function Group() { function Group() {
const { group } = useParams(); const { csrfToken, user, setUser } = useContext(GlobalContext);
const [ user, setUser ] = useState(null); const groupId = useParams().group;
const [ group, setGroup ] = useState(null);
const [ error, setError ] = useState(null);
useEffect(() => {
if (!user || !groupId || !csrfToken) {
return;
}
const effect = async () => {
const res = await window.fetch(
`${base}/api/v1/group/${groupId}/events`, {
method: 'POST',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken
}
});
const data = await res.json();
if (res.status >= 400) {
setError(data.message ? data.message : res.statusText);
return;
}
if (!data) {
return;
}
setGroup(data);
};
effect();
}, [user, setGroup, groupId, csrfToken]);
return ( return (
<div className="Group"> <Paper className="Group">
<GlobalContext.Provider value={{user, setUser}}> <GlobalContext.Provider value={{user, setUser}}>
Group: {group} Group: {groupId}
</GlobalContext.Provider> </GlobalContext.Provider>
</div> </Paper>
); );
} }

View File

@ -40,11 +40,7 @@ export default function SignIn() {
const handleSubmit = (event) => { const handleSubmit = (event) => {
event.preventDefault(); event.preventDefault();
const data = new FormData(event.currentTarget); const data = new FormData(event.currentTarget);
console.log({ window.fetch(`${base}/api/v1/users/signin`, {
email: data.get('email'),
password: data.get('password'),
});
window.fetch(`${base}/api/v1/users/login`, {
method: 'POST', method: 'POST',
cache: 'no-cache', cache: 'no-cache',
credentials: 'same-origin', credentials: 'same-origin',

View File

@ -1,4 +1,4 @@
import * as React from 'react'; import React, { useContext, useState } from 'react';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
@ -12,7 +12,11 @@ import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container'; import Container from '@mui/material/Container';
import { createTheme, ThemeProvider } from '@mui/material/styles'; import { createTheme, ThemeProvider } from '@mui/material/styles';
import { Link as ReactLink } from "react-router-dom";
import { Link as ReactLink, useNavigate } from "react-router-dom";
import { GlobalContext } from "./GlobalContext.js";
import { base } from "./Common.js";
function Copyright(props) { function Copyright(props) {
return ( return (
@ -30,12 +34,44 @@ function Copyright(props) {
const theme = createTheme(); const theme = createTheme();
export default function SignUp() { export default function SignUp() {
const { setUser, csrfToken } = useContext(GlobalContext);
const [error, setError] = useState(undefined);
const navigate = useNavigate();
const handleSubmit = (event) => { const handleSubmit = (event) => {
event.preventDefault(); event.preventDefault();
const data = new FormData(event.currentTarget); const data = new FormData(event.currentTarget);
console.log({ window.fetch(`${base}/api/v1/users/signup`, {
email: data.get('email'), method: 'POST',
password: data.get('password'), cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken
},
body: JSON.stringify({
email: data.get('email'),
firstName: data.get('firstName'),
familyName: data.get('lastName'),
password: data.get('password')
})
})
.then(async (res) => {
return [res.status, res.statusText, await res.json()];
})
.then(([status, statusText, data]) => {
if (status >= 400) {
setError(data.message ? data.message : statusText);
return null;
} if (!data) {
return;
}
setUser(data);
navigate("/");
})
.catch((error) => {
console.error(`Error: `, error);
setError(`Error authenticating.`);
}); });
}; };
@ -102,6 +138,10 @@ export default function SignUp() {
/> />
</Grid> </Grid>
</Grid> </Grid>
{error && <Typography component="h2" sx={{ color: 'red' }} >
{error}
</Typography>
}
<Button <Button
type="submit" type="submit"
fullWidth fullWidth

73
client/src/VerifyEmail.js Normal file
View File

@ -0,0 +1,73 @@
import React, { useState, useEffect, useContext } from "react";
import {
useParams,
useNavigate
} from "react-router-dom";
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import './Group.css';
import { GlobalContext } from "./GlobalContext.js";
import { base } from "./Common.js";
function Group() {
const navigate = useNavigate();
const { csrfToken } = useContext(GlobalContext);
const { token } = useParams();
const [ user, setUser ] = useState(null);
const [error, setError ] = useState(undefined);
useEffect(() => {
if (!token || !csrfToken) {
return;
}
const effect = async () => {
const res = await window.fetch(`${base}/api/v1/users/verify-email`, {
method: 'POST',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'CSRF-Token': csrfToken
},
body: JSON.stringify({
token
})
});
const data = await res.json();
if (res.status >= 400) {
setError(data.message ? data.message : res.statusText);
return;
}
if (!data) {
return;
}
setUser(data);
navigate("/");
}
effect();
}, [setError, token, csrfToken, navigate]);
return (
<Paper className="Group" style={{display: "flex", flexDirection: "column"}}>
<GlobalContext.Provider value={{user, setUser}}>
<div>
Verifying authentication token.
</div>
</GlobalContext.Provider>
{error &&
<Paper style={{
padding: "0.5rem"
}}>
<Typography component="h2" sx={{ color: 'red' }} >
{error}
</Typography>
</Paper>
}
</Paper>
);
}
export default Group;

Binary file not shown.

Binary file not shown.

View File

@ -28,7 +28,7 @@ app.use(csrf({
cookie: true cookie: true
})); }));
const ws = require('express-ws')(app, server); //const ws = require('express-ws')(app, server);
require("./console-line.js"); /* Monkey-patch console.log with line numbers */ require("./console-line.js"); /* Monkey-patch console.log with line numbers */
@ -101,7 +101,7 @@ require("./db/groups").then(function(db) {
}).then(function() { }).then(function() {
console.log("DB connected. Opening server."); console.log("DB connected. Opening server.");
server.listen(serverConfig.port, () => { server.listen(serverConfig.port, () => {
console.log(`http/ws server listening on ${serverConfig.port}`); console.log(`http server listening on ${serverConfig.port}`);
}); });
}).catch(function(error) { }).catch(function(error) {
console.error(error); console.error(error);

View File

@ -1,8 +1,6 @@
"use strict"; "use strict";
const fs = require('fs'), const Sequelize = require('sequelize'),
path = require('path'),
Sequelize = require('sequelize'),
config = require('config'); config = require('config');
function init() { function init() {
@ -19,14 +17,31 @@ function init() {
autoIncrement: true autoIncrement: true
}, },
name: Sequelize.STRING, name: Sequelize.STRING,
ownerId: Sequelize.INTEGER
}, { }, {
timestamps: false, timestamps: false,
classMethods: { classMethods: {
associate: function() { associate: function() {}
}
} }
}); });
const GroupUsers = db.sequelize.define('groupuser', {
groupId: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: Group,
key: 'id'
}
},
userId: {
type: Sequelize.INTEGER,
allowNull: false,
}
}, {
timestamps: false
});
return db.sequelize.sync({ return db.sequelize.sync({
force: false force: false
}).then(function () { }).then(function () {

View File

@ -16,15 +16,15 @@ function init() {
primaryKey: true, primaryKey: true,
autoIncrement: true autoIncrement: true
}, },
displayName: Sequelize.STRING, familyName: Sequelize.STRING,
notes: Sequelize.STRING, firstName: Sequelize.STRING,
uid: Sequelize.STRING, uid: Sequelize.STRING,
md5: Sequelize.STRING, md5: Sequelize.STRING, /* md5 of email address for Gravatar */
authToken: Sequelize.STRING, authToken: Sequelize.STRING,
authDate: Sequelize.DATE, authDate: Sequelize.DATE,
authenticated: Sequelize.BOOLEAN, authenticated: Sequelize.BOOLEAN,
mailVerified: Sequelize.BOOLEAN, mailVerified: Sequelize.BOOLEAN,
mail: Sequelize.STRING, email: Sequelize.STRING,
memberSince: Sequelize.DATE, memberSince: Sequelize.DATE,
password: Sequelize.STRING, /* SHA hash of user supplied password */ password: Sequelize.STRING, /* SHA hash of user supplied password */
passwordExpires: Sequelize.DATE passwordExpires: Sequelize.DATE

View File

@ -4,25 +4,33 @@ const config = require("config"),
crypto = require("crypto"), crypto = require("crypto"),
hb = require("handlebars"); hb = require("handlebars");
const createTransport = require('nodemailer').createTransport;
const transporter = createTransport({
host: 'ketrenos.com',
pool: true,
port: 25
});
const templates = { const templates = {
"verify": { "verify": {
"html": [ "html": [
"<p>Hello {{username}},</p>", "<p>Hello {{firstName}},</p>",
"", "",
"<p>Welcome to <b>goodtimes.ketrenos.com</b>. You are almost done creating your account. ", "<p>Welcome to <b>goodtimes.ketrenos.com</b>. You are almost done creating your account. ",
"Before you can access the system, you must verify your email address.</p>", "Before you can access the system, you must verify your email address {{email}}.</p>",
"", "",
"<p>To do so, simply access this link:</p>", "<p>To do so, simply access this link:</p>",
"<p><a href=\"{{url}}{{secret}}\">VERIFY {{mail}} ADDRESS</a></p>", "<p><a href=\"{{url}}{{secret}}\">VERIFY ADDRESS</a></p>",
"", "",
"<p>Sincerely,</p>", "<p>Sincerely,</p>",
"<p>James</p>" "<p>James</p>"
].join("\n"), ].join("\n"),
"text": [ "text": [
"Hello {{username}},", "Hello {{firstName}},",
"", "",
"Welcome to goodtimes.ketrenos.com. You are almost done creating your account. ", "Welcome to goodtimes.ketrenos.com. You are almost done creating your account. ",
"Before you can access the system, you must verify your email address.", "Before you can access the system, you must verify your email address {{email}}.",
"", "",
"To do so, simply access this link:", "To do so, simply access this link:",
"", "",
@ -32,19 +40,35 @@ const templates = {
"James" "James"
].join("\n") ].join("\n")
}, },
"password": { "verified": {
"html": [ "html": [
"<p>Hello {{username}},</p>", "<p>The user {{email}} verified their address and is now active",
"", "on Goodtimes.</p>",
"<p>You changed your password on <b>ketrenos.com</b>.</p>",
"", "",
"<p>Sincerely,</p>", "<p>Sincerely,</p>",
"<p>James</p>" "<p>James</p>"
].join("\n"), ].join("\n"),
"text": [ "text": [
"Hello {{username}},", "The user {{email}} verified their address and is now active",
"on Goodtimes.",
"", "",
"You changed your password on ketrenos.com.", "Sincerely,",
"James"
].join("\n")
},
"password": {
"html": [
"<p>Hello {{firstName}},</p>",
"",
"<p>You changed your password on <b>goodtimes.ketrenos.com</b>.</p>",
"",
"<p>Sincerely,</p>",
"<p>James</p>"
].join("\n"),
"text": [
"Hello {{firstName}},",
"",
"You changed your password on goodtimes.ketrenos.com.",
"", "",
"Sincerely,</p>", "Sincerely,</p>",
"James" "James"
@ -82,21 +106,21 @@ const sendVerifyMail = function(userDB, req, user) {
throw error; throw error;
}); });
}).then(function(secret) { }).then(function(secret) {
const transporter = req.app.get("transporter");
if (!transporter) { if (!transporter) {
console.log("Not sending VERIFY email; SMTP not configured."); console.log("Not sending VERIFY email; SMTP not configured.");
return; return;
} }
let data = { let data = {
username: user.displayName, firstName: user.firstName,
mail: user.mail, email: user.email,
secret: secret, secret: secret,
url: req.protocol + "://" + req.hostname + req.app.get("basePath") url: req.protocol + "://" + req.hostname + req.app.get("basePath") +
"/user/verify-email/"
}, envelope = { }, envelope = {
to: data.mail, to: data.email,
from: config.get("smtp.sender"), from: config.get("smtp.sender"),
subject: "Request to ketrenos.com create account for '" + data.username + "'", subject: "Request to goodtimes.ketrenos.com create account for '" + data.firstName + "'",
cc: "", cc: "",
bcc: config.get("admin.mail"), bcc: config.get("admin.mail"),
text: hb.compile(templates.verify.text)(data), text: hb.compile(templates.verify.text)(data),
@ -119,7 +143,7 @@ const sendVerifyMail = function(userDB, req, user) {
} }
attempts--; attempts--;
console.log("Unable to send mail. Trying again in 100ms (" + attempts + " attempts remain): ", error); console.log("Unable to send email. Trying again in 100ms (" + attempts + " attempts remain): ", error);
setTimeout(send.bind(undefined, envelope), 100); setTimeout(send.bind(undefined, envelope), 100);
}); });
} }
@ -132,20 +156,19 @@ const sendVerifyMail = function(userDB, req, user) {
}; };
const sendPasswordChangedMail = function(userDB, req, user) { const sendPasswordChangedMail = function(userDB, req, user) {
const transporter = req.app.get("transporter");
if (!transporter) { if (!transporter) {
console.log("Not sending VERIFY email; SMTP not configured."); console.log("Not sending VERIFY email; SMTP not configured.");
return; return;
} }
let data = { let data = {
username: user.displayName, firstName: user.firstName,
mail: user.mail, email: user.email,
url: req.protocol + "://" + req.hostname + req.app.get("basePath") url: req.protocol + "://" + req.hostname + req.app.get("basePath")
}, envelope = { }, envelope = {
to: data.mail, to: data.email,
from: config.get("smtp.sender"), from: config.get("smtp.sender"),
subject: "Password changed on ketrenos.com for '" + data.username + "'", subject: "Password changed on goodtimes.ketrenos.com for '" + data.firstName + "'",
cc: "", cc: "",
bcc: config.get("admin.mail"), bcc: config.get("admin.mail"),
text: hb.compile(templates.password.text)(data), text: hb.compile(templates.password.text)(data),
@ -168,7 +191,50 @@ const sendPasswordChangedMail = function(userDB, req, user) {
} }
attempts--; attempts--;
console.log("Unable to send mail. Trying again in 100ms (" + attempts + " attempts remain): ", error); console.log("Unable to send email. Trying again in 100ms (" + attempts + " attempts remain): ", error);
setTimeout(send.bind(undefined, envelope), 100);
});
}
send(envelope);
});
};
const sendVerifiedMail = function (userDB, req, user) {
if (!transporter) {
console.log("Not sending VERIFIED email; SMTP not configured.");
return;
}
const envelope = {
to: config.get("admin.mail"),
from: config.get("smtp.sender"),
subject: "VERIFIED: Account'" + user.email + "'",
cc: "",
bcc: "",
text: hb.compile(templates.verified.text)(user),
html: hb.compile(templates.verified.html)(user)
};
return new Promise(function (resolve, reject) {
let attempts = 10;
function send(envelope) {
/* Rate limit to ten per second */
transporter.sendMail(envelope, function (error, info) {
if (!error) {
console.log('Message sent: ' + info.response);
return resolve();
}
if (attempts == 0) {
console.log("Error sending email: ", error)
return reject(error);
}
attempts--;
console.log("Unable to send mail. Trying again in 100ms (" +
attempts + " attempts remain): ", error);
setTimeout(send.bind(undefined, envelope), 100); setTimeout(send.bind(undefined, envelope), 100);
}); });
} }
@ -179,5 +245,6 @@ const sendPasswordChangedMail = function(userDB, req, user) {
module.exports = { module.exports = {
sendVerifyMail, sendVerifyMail,
sendVerifiedMail,
sendPasswordChangedMail sendPasswordChangedMail
} }

View File

@ -38,7 +38,19 @@ router.get("/", (req, res/*, next*/) => {
return res.status(200).send({ user: userId }); return res.status(200).send({ user: userId });
}); });
router.post("/:id?", async (req, res/*, next*/) => { router.post("/", async (req, res/*, next*/) => {
console.log(`POST /groups/`, req.session.userId);
return res.status(200).send(
[ {
id: 1,
ownerId: 1,
name: "Beer Tuesday",
nextEvent: Date.now() + 86400 * 14 * 1000 /* 2 weeks from now */
} ]
);
});
router.post("/:id", async (req, res/*, next*/) => {
const { id } = req.params; const { id } = req.params;
let userId; let userId;

View File

@ -2,10 +2,11 @@
const express = require("express"), const express = require("express"),
config = require("config"), config = require("config"),
{ sendVerifyMail, sendPasswordChangedMail } = require("../lib/mail"), { sendVerifyMail, sendPasswordChangedMail, sendVerifiedMail } = require("../lib/mail"),
crypto = require("crypto"); crypto = require("crypto");
const router = express.Router(); const router = express.Router();
const autoAuthenticate = 1;
let userDB; let userDB;
@ -82,57 +83,75 @@ router.get("/csrf", (req, res) => {
res.json({ csrfToken: req.csrfToken() }); res.json({ csrfToken: req.csrfToken() });
}); });
router.post("/create", function(req, res) { router.post("/signup", function(req, res) {
console.log("/users/create"); console.log("/users/signup");
const user = { const user = {
uid: req.query.m || req.body.m, uid: req.body.email,
displayName: req.query.n || req.body.n || "", familyName: req.body.familyName,
password: req.query.p || req.body.p || "", firstName: req.body.firstName,
mail: req.query.m || req.body.m, password: req.body.password,
notes: req.query.w || req.body.w || "" email: req.body.email,
}; };
if (!user.uid || !user.password || !user.displayName || !user.notes) { if (!user.uid
return res.status(400).send("Missing email address, password, name, and/or who you know."); || !user.email
|| !user.password
|| !user.familyName
|| !user.firstName) {
return res.status(400).send({
message: `Missing email address, password, and/or name.`
});
} }
user.password = crypto.createHash('sha256') user.password = crypto.createHash('sha256')
.update(user.password).digest('base64'); .update(user.password).digest('base64');
user.md5 = crypto.createHash('md5') user.md5 = crypto.createHash('md5')
.update(data).digest('base64'); .update(user.email).digest('base64');
return userDB.sequelize.query("SELECT * FROM users WHERE uid=:uid", { return userDB.sequelize.query("SELECT * FROM users WHERE uid=:uid", {
replacements: user, replacements: user,
type: userDB.Sequelize.QueryTypes.SELECT, type: userDB.Sequelize.QueryTypes.SELECT,
raw: true raw: true
}).then(function(results) { }).then(async function(results) {
if (results.length != 0) { if (results.length != 0 && results[0].mailVerified) {
return res.status(400).send("Email address already used."); return res.status(400).send({
message: `Email address already used.`
});
} }
let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; let re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
if (!re.exec(user.mail)) { if (!re.exec(user.email)) {
console.log("Invalid email address: " + user.mail); const error = `Invalid email address: ${user.email}.`;
throw "Invalid email address."; console.log(error);
return res.status(401).send({
message: error
});
} }
}).then(function() {
return userDB.sequelize.query("INSERT INTO users " + try {
"(uid,displayName,password,mail,memberSince,authenticated,notes,md5) " + if (results.length != 0) {
"VALUES(:uid,:displayName,:password,:mail,CURRENT_TIMESTAMP,0,:notes,:md5)", { await userDB.sequelize.query("UPDATE users " +
replacements: user "SET mailVerified=0");
}).spread(function(results, metadata) { req.session.userId = results[0].id;
req.session.userId = metadata.lastID; } else {
}).then(function() { let [, metadata] = await userDB.sequelize.query("INSERT INTO users " +
"(uid,firstName,familyName,password,email,memberSince," +
"authenticated,md5) " +
`VALUES(:uid,:firstName,:familyName,:password,` +
`:email,CURRENT_TIMESTAMP,${autoAuthenticate},:md5)`, {
replacements: user
});
req.session.userId = metadata.lastID;
}
return getSessionUser(req).then(function(user) { return getSessionUser(req).then(function(user) {
res.status(200).send(user); res.status(200).send(user);
user.id = req.session.userId; user.id = req.session.userId;
return sendVerifyMail(userDB, req, user); return sendVerifyMail(userDB, req, user);
}); });
}).catch(function(error) { } catch (error) {
console.log("Error creating account: ", error); console.error(error);
return res.status(401).send(error); }
});
}); });
}); });
@ -143,7 +162,7 @@ const getSessionUser = function(req) {
} }
let query = "SELECT " + let query = "SELECT " +
"uid AS username,displayName,mailVerified,authenticated,memberSince AS name,mail " + "uid AS username,firstName,familyName,mailVerified,authenticated,memberSince,email,md5 " +
"FROM users WHERE id=:id"; "FROM users WHERE id=:id";
return userDB.sequelize.query(query, { return userDB.sequelize.query(query, {
replacements: { replacements: {
@ -173,7 +192,7 @@ const getSessionUser = function(req) {
}).then(function(user) { }).then(function(user) {
/* Strip out any fields that shouldn't be there. The allowed fields are: */ /* Strip out any fields that shouldn't be there. The allowed fields are: */
let allowed = [ let allowed = [
"maintainer", "username", "displayName", "mailVerified", "authenticated", "name", "mail", "restriction" "maintainer", "username", "firstName", "familyName", "mailVerified", "authenticated", "name", "email", "restriction", "md5"
]; ];
for (let field in user) { for (let field in user) {
if (allowed.indexOf(field) == -1) { if (allowed.indexOf(field) == -1) {
@ -184,13 +203,70 @@ const getSessionUser = function(req) {
}); });
} }
router.post("/login", function(req, res) { router.post("/verify-email", async (req, res) => {
console.log("/users/login"); console.log("/users/verify-email");
const key = req.body.token;
let results = await userDB.sequelize.query(
"SELECT * FROM authentications WHERE key=:key", {
replacements: { key },
type: userDB.sequelize.QueryTypes.SELECT
});
let token;
if (results.length == 0) {
console.log("Invalid key. Ignoring.");
return res.status(400).send({
message: `Invalid authentication token.`
});
}
token = results[0];
console.log(token);
console.log("Matched token: " + JSON.stringify(token, null, 2));
switch (token.type) {
case "account-setup":
return userDB.sequelize.query(
"UPDATE users SET mailVerified=1 WHERE id=:userId", {
replacements: token
})
.then(function () {
return userDB.sequelize.query(
"DELETE FROM authentications WHERE key=:key", {
replacements: { key }
});
})
.then(function () {
return userDB.sequelize.query(
"SELECT * FROM users WHERE id=:userId", {
replacements: token,
type: userDB.sequelize.QueryTypes.SELECT
});
})
.then(function (results) {
if (results.length == 0) {
return res.status(500).send({
message: `Internal authentication error.`
});
}
return results[0];
})
.then((user) => {
sendVerifiedMail(userDB, req, user);
req.session.userId = user.id;
}).then(function (user) {
return getSessionUser(req).then(function (user) {
return res.status(200).send(user);
});
});
}
});
router.post("/signin", function(req, res) {
console.log("/users/signin");
let { email, password } = req.body; let { email, password } = req.body;
console.log("Login attempt");
if (!email || !password) { if (!email || !password) {
return res.status(400).send({ return res.status(400).send({
message: `Missing email and/or password` message: `Missing email and/or password`
@ -202,7 +278,7 @@ router.post("/login", function(req, res) {
let query = "SELECT " + let query = "SELECT " +
"id,mailVerified,authenticated," + "id,mailVerified,authenticated," +
"uid AS username," + "uid AS username," +
"displayName AS name,mail " + "familyName,firstName,email " +
"FROM users WHERE uid=:username AND password=:password"; "FROM users WHERE uid=:username AND password=:password";
return userDB.sequelize.query(query, { return userDB.sequelize.query(query, {
replacements: { replacements: {
@ -224,11 +300,11 @@ router.post("/login", function(req, res) {
console.log(email + " not found (or invalid password.)"); console.log(email + " not found (or invalid password.)");
req.session.userId = null; req.session.userId = null;
return res.status(401).send({ return res.status(401).send({
message: `Invalid login credentials` message: `Invalid sign in credentials`
}); });
} }
let message = "Logged in as " + user.displayName + " (" + user.id + ")"; let message = "Logged in as " + user.email + " (" + user.id + ")";
if (!user.mailVerified) { if (!user.mailVerified) {
console.log(message + ", who is not verified email."); console.log(message + ", who is not verified email.");
} else if (!user.authenticated) { } else if (!user.authenticated) {
@ -247,9 +323,8 @@ router.post("/login", function(req, res) {
}); });
}); });
router.get("/logout", function(req, res) { router.post("/signout", (req, res) => {
console.log("/users/logout"); console.log("/users/signout");
if (req.session && req.session.userId) { if (req.session && req.session.userId) {
req.session.userId = null; req.session.userId = null;
} }