-
Notifications
You must be signed in to change notification settings - Fork 1
04. Logging in with PassportJS
As 1batch becomes a real app, it needs features that are common to many social apps:
- return only public info about users (no passwords)
- use user names (such as mapmeld) and not MongoIDs
- prevent users from being created with the same user name
- allow users to see private info for their own account (still no passwords)
- allow users to follow and block other users
- guarantee that users can post only from the website
Each of these steps involves authentication.
Although there is client-side code involved in deciding what users can do, a malicious user could modify the webpage in their browser and mimic requests. The real code for user controls must happen on the server side.
There's an attack known as Cross-Site Request Forgery (CSRF) where a user can click a link on another site, and if they're logged onto your site, it could make posts and changes to their account. This problem is solved by sending unique passwords to each user known as CSRF Tokens, and including those tokens with any change requests.
To implement this solution, install the koa-csrf module (and koa-convert, if you haven't added it yet)
npm install koa-csrf --save
In the top of app.js, set up protection:
const csrf = require('koa-csrf');
...
csrf(app);
app.use(convert(csrf.middleware));This change affects every form on your site, and automatically blocks requests if a bad post gets made. The server-side code looks like this:
router.get('/form', function (ctx) {
ctx.render('form', {
csrfToken: ctx.csrf
});
});
app.post('/form', function (ctx) { ... });Inside the Pug template, the form should look like this:
form(action="/example", method="POST")
input(type="hidden", name="_csrf", value=csrfToken)
input(type="submit", value="Post")I've created a login.js file to handle most things related to users and logins. Pass the Express app and csrfProtection variables to a new setupAuth() function, where we can make new requests.
// app.js side
var setupAuth = require('./login.js').setupAuth;
...
setupAuth(app, router);Then on login.js, create a basic structure for registering and logging in users.
To make setupAuth() available to app.js, we have to use module.exports.
// login.js side
function setupAuth(app, router) {
// page to register a new user, and POST from form
router.get('/register', function(ctx) { })
.post('/register', function(ctx) { })
// page to login an existing user, and POST from form
.get('/login', function(ctx) { })
.post('/login', function(ctx) { })
}
module.exports = {
setupAuth: setupAuth
};The registration and login pages are relatively easy to set up in this first pass. Here's how I would set up registration on the Pug template:
// register.pug
form(action="/register", method="POST")
input(type="hidden", name="_csrf", value=csrfToken)
label User name
input(type="text", name="username")
br
label Password
input(type="password", name="password")
br
input(type="submit", value="Register")On the server side, we know how to render a Pug template and make the form appear. We just don't know how to process the user yet. Here's how to make the form appear:
router.get('/register', function(ctx) {
ctx.render('register', { csrfToken: ctx.csrf });
});The login.pug form will look exactly the same (except posting to /login), and its server-side code will look like this:
router.get('/login', function(ctx) {
ctx.render('login', { csrfToken: ctx.csrf });
});User logins and connecting to social media accounts (Google, Facebook, Twitter), are best handled by the PassportJS module and its plugins. Here "passport-local" is an internal password system. Here's what you should install to get started:
npm install koa-passport@next passport-local --save
Your login.js module will need to be rewritten a bit here... the password confirmation happens inside passport code.
const passport = require('koa-passport');
const LocalStrategy = require('passport-local').Strategy;
function setupAuth (app, router) {
app.use(passport.initialize());
app.use(passport.session());
...
}
passport.use(new LocalStrategy(function(username, password, cb) {
// load the user object
User.findOne({ username: username.trim().toLowerCase() }, function(err, user) {
if (err) { return cb(err); }
// this username doesn't exist
if (!user) { return cb(null, false); }
// does this password match? NOT SECURE DO NOT USE IN REAL LIFE
if (password !== user.password) { return cb(null, false); }
// successful login
return cb(null, user);
});
}));
// these ones I don't understand so well, sorry
passport.serializeUser(function(user, done) {
done(null, user._id);
});
passport.deserializeUser(function(id, done) {
User.findById(id, done);
});
router.post('/login', passport.authenticate('local', {
successRedirect: '/profile?justLoggedIn=true',
failureRedirect: '/login'
}));
router.post('/register', async function (ctx) {
// this is a little more complicated because I might need to create the user
var username = ctx.request.body.username.trim().toLowerCase();
var pwd = ctx.request.body.password;
// anticipate issues with submitted username
if (!username.length) {
throw 'user name is blank';
}
var users = await User.find({ name: username }).exec();
if (users.length) {
throw 'user with that name already exists';
}
// create a new user
var u = new User({
username: username,
password: pwd // NOT secure, stores password in plaintext
});
await u.save();
// end by sending user to re-enter password on login screen
ctx.redirect('/login?user=' + u.name);
});You shouldn't store any real user passwords in plaintext! Not only is it bad practice for your admins to see everyone's passwords, you might be held responsible if the database is hacked sometime.
The current understanding is that you should hash user passwords. This can be easy to reverse-engineer, so you salt user passwords, too with a user-specific string. Here's what you should do:
In models/user.js, replace the password with two fields: a hash and a salt
var userSchema = mongoose.Schema({
username: { type: String, lowercase: true },
hash: String,
salt: String
});In login.js:
// this is a built-in NodeJS module
const crypto = require('crypto');
passport.use(new LocalStrategy(function(username, password, cb) {
User.findOne({ name: username.trim().toLowerCase() }, function(err, user) {
// handle any issues finding user
if (err) { return cb(err); }
if (!user) { return cb(null, false); }
// generate hash based on submitted password and user-specific salt
var hash = crypto.pbkdf2Sync(password, user.salt, iterations, len, 'sha256');
hash = hash.toString('base64');
// does hash from this match the original hash?
if (hash !== user.localpass) { return cb(null, false); }
return cb(null, user);
});
}));
router.post('/register', async function (ctx) {
var username = ctx.request.body.username.trim().toLowerCase();
var pwd = ctx.request.body.password;
// anticipate issues with submitted username
if (!username.length) {
throw 'user name is blank';
}
var users = await User.find({ name: username }).exec();
if (users.length) {
throw 'user with that name already exists';
}
// generate user-specific salt, and use that to generate the hash
var len = 128;
var iterations = 12000;
var salt, hash;
salt = await crypto.randomBytes(len);
salt = salt.toString('base64');
hash = crypto.pbkdf2Sync(pwd, salt, iterations, len, 'sha256');
hash = hash.toString('base64');
var u = new User({
username: username,
hash: hash,
salt: salt
});
await u.save();
ctx.redirect('/login?user=' + u.name);
});