You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

241 lines
8.4 KiB

/*
Implements everything needed for a [Passport.js](https://www.passportjs.org) middleware.
It supports either a Redis store or a MemoryStore. The MemoryStore can be used in development
if you can't or won't run Redis while you work. Ultimately you should be using the Redis
store even in development as it keeps you logged in while you work which is easier.
The type of store is configured in the `secrets/config.json` file (which is loaded into `lib/config.js`) using the `session_store` option. See `configure_sessions` for more information on this
configuration option.
The most important part of this module is the `socket` function, which adds `Passport.js`
authentication to `socket.io` which is something missing from `Passport.js`. Look at that
if you wondered how to do that.
*/
import cookie from 'cookie';
import { Strategy } from 'passport-local';
import passport from 'passport';
import signature from 'cookie-signature';
import redis from 'redis';
import session from 'express-session';
import memorystore from 'memorystore';
import connectRedis from 'connect-redis';
import * as models from '../lib/models.js';
import logging from '../lib/logging.js';
import { session_store, auth } from "../lib/config.js";
import assert from "assert";
const log = logging.create("lib/auth.js");
const redisClient = redis.createClient();
const MemoryStore = memorystore(session);
const RedisStore = connectRedis(session);
passport.use(new Strategy(
async (username, password, cb) => {
let user = await models.User.auth(username, password);
return user ? cb(null, user) : cb(null, false);
})
);
passport.serializeUser((user, cb) => {
cb(null, user.id);
});
passport.deserializeUser(async (id, cb) => {
try {
const user = await models.User.first({id});
if(user) {
return cb(null, user);
} else {
return cb(null, false);
}
} catch(error) {
log.error(error);
return cb(null, false);
}
});
/*
Configures `Passport.js` to use either `RedisStore` or `MemoryStore`. If you want to
add a different store then modify this function. If the `config.json:session_store` is
`"redis"` you'll get the `RedisStore`. If it's set to `"memory"` then it will use the
`MemoryStore`.
___WARNING___: You don't really call this and instead use the `sessionStore` variable in
this module.
+ ___return___ (RedisStore|MemoryStore|undefined) -- The store to use or `undefined` on error.
*/
export const configure_sessions = () => {
if(session_store === "redis") {
log.info("Using redis to store sessions.");
return new RedisStore({client: redisClient});
} else if(session_store === "memory") {
log.warn("Using memory to store sessions. Restarts will force logins.");
return new MemoryStore({ checkPeriod: 86400000, });
} else {
assert(false, `Error, unknown session store ${session_store} in secrets/config.json. Only "redis" and "memory" supported.`);
return undefined; // shouldn't reach this but shut up eslint
}
}
/*
* The configured session store being used. Just use this instead of
* calling `configure_sessions` yourself.
*/
export const sessionStore = configure_sessions();
/*
The cookie secret configured in `secrets/config.json:auth`.
___FOOTGUN___: Don't expose this to the internet!
*/
export const cookie_secret = auth.cookie_secret;
/*
The cookie _domain_ configured in `secrets/config.json:auth`. If this isn't
exactly the same as your domain then you'll not be able to log in. You can
run:
```shell
node bando.js api --cookies-suck
```
This will output cookie debugging information so you can figure out what's going on.
Incidentally, debugging the stupid cookies ends up a huge chunk of the code in `commands/api.js`
because, you guessed it, `--cookies-suck`.
*/
export const cookie_domain = auth.cookie_domain;
assert(cookie_domain !== undefined, "secrets/config.json:auth.cookie_domain isn't set.");
/*
This does the actual `Passport.js` login, but you wouldn't necessarily use this
directly unless you are making something custom. Instead, you "tag" handlers
in `api/` handlers with `.login = true` and then the `commands/api.js` will
automatically run this before running your handler. Look in `api/login.js` to see
how to use `post.login = true` to actually use this.
If you want to see how this is used, look for `auth.login` in `commands/api.js`.
+ ___return__ Function -- Returns a Express.js handler that does the `passport.authenticate`.
*/
export const login = () => {
return (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if(err) {
log.error(err, info);
return res.status(500).json({error: info});
} else if(!user) {
// log.debug(`Auth attempt user is ${user.id}.`);
return res.status(401).json({error: "Authentication required"});
} else {
// log.debug(`User exists, attempting login ${user.id}`);
return req.login(user, (err) => {
if(err) {
log.error(err);
res.status(403).json({error: "Invalid login"});
// BUG: is this right or is next("route")
return next(err);
} else {
return next();
}
});
}
})(req, res, next);
}
}
/*
An Express.js handler that make authentication required. Just like with `login` it
is normally used by adding the `.authenticated = true` tag to your handlers. If you
do `get.authenticated = true` then that `get` will require a login before it runs.
The `commands/api.js` command is the one that uses this `require` function to add that
feature to your handlers in `api/`.
*/
export const required = () => {
return (req, res, next) => {
const user = req.user;
if(!user) {
return res.status(401).json({error: "Authentication required"});
// WARNING: next('route') is NOT needed to abort the chain of calls despite express docs
} else {
return next();
}
}
}
/*
* This is added to any `socket/` handlers tagged with `.authenticated`. It does
* the same thing as `required` above but instead does all the fairly stupid things
* you need to authenticate a `socket.io` connection.
*
* It's main purpose is to authenticate, but _also_ to connect the `req.user` to the
* socket so you can access the user in your `socket/` handlers.
*
* + `socket Object` -- A socket.io socket to authenticate.
* + ___return___ `boolean` -- A `Promise` that performs the authentication then adds the `req.user` to the socket.
*/
export const socket = async (socket) => {
try {
if(!socket.handshake.headers.cookie) {
throw new Error("No cookies in socket handshake headers, disconnecting socket.");
}
let cookies = cookie.parse(socket.handshake.headers.cookie);
if(!cookies) {
throw new Error("No cookies were parsed even though the header exists, disconnecting socket.");
}
const connect_sid = cookies['connect.sid'];
if(!connect_sid) {
throw new Error("No connect.sid cookie in the list of cookies, disconnecting socket.");
}
if(connect_sid.slice(0, 2) !== 's:') {
throw new Error(`is_authenticate only supports signed cookies. Your cookies don't start with s: they start with ${connect_sid.slice(0, 2)}`);
}
let session_id = signature.unsign(connect_sid.slice(2), cookie_secret);
log.debug(`Session id is ${session_id}`);
if(!session_id) {
throw new Error(`Unable to get session_id ${session_id} with session signature.`);
}
return new Promise((resolve, reject) => {
sessionStore.get(session_id, (err, session) => {
socket.express_session = session;
if(err) {
log.error(err);
reject(err);
} else if(session && session.passport && session.passport.user !== undefined) {
socket.user_id = session.passport.user;
resolve(true);
} else {
socket.disconnect();
delete socket.user;
delete socket.express_session;
resolve(false);
}
});
});
} catch(error) {
// not logging the error stack trace for now since i'm using the Error as a jump/clean
log.debug(error.message);
socket.disconnect();
delete socket.user;
delete socket.express_session;
return false;
}
}
/*
Initializes your Express.js application with the `Passport.js` required handlers.
*/
export const init = (app) => {
app.use(passport.initialize());
app.use(passport.session());
}