wip: move all authentication to passport, including JWT and add new AccessToken and RefreshToken (which are not issued yet)

This commit is contained in:
Paul Bienkowski 2021-02-18 19:56:51 +01:00
parent fc99d8a03b
commit 254b262a72
11 changed files with 326 additions and 76 deletions

40
api/package-lock.json generated
View file

@ -6243,6 +6243,16 @@
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ=="
}, },
"oauth2orize": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/oauth2orize/-/oauth2orize-1.11.0.tgz",
"integrity": "sha1-eTzvJR1F696sMq5AqLaBT6qx1IM=",
"requires": {
"debug": "2.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
}
},
"object-assign": { "object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -6471,6 +6481,31 @@
"pause": "0.0.1" "pause": "0.0.1"
} }
}, },
"passport-custom": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz",
"integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==",
"requires": {
"passport-strategy": "1.x.x"
}
},
"passport-http-bearer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/passport-http-bearer/-/passport-http-bearer-1.0.1.tgz",
"integrity": "sha1-FHRp6jZp4qhMYWfvmdu3fh8AmKg=",
"requires": {
"passport-strategy": "1.x.x"
}
},
"passport-jwt": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.0.tgz",
"integrity": "sha512-BwC0n2GP/1hMVjR4QpnvqA61TxenUMlmfNjYNgK0ZAs0HK4SOQkHcSv4L328blNTLtHq7DbmvyNJiH+bn6C5Mg==",
"requires": {
"jsonwebtoken": "^8.2.0",
"passport-strategy": "^1.0.0"
}
},
"passport-local": { "passport-local": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
@ -8566,6 +8601,11 @@
"random-bytes": "~1.0.0" "random-bytes": "~1.0.0"
} }
}, },
"uid2": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
"integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I="
},
"undefsafe": { "undefsafe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz",

View file

@ -41,8 +41,12 @@
"mongoose-unique-validator": "2.0.3", "mongoose-unique-validator": "2.0.3",
"morgan": "1.10.0", "morgan": "1.10.0",
"nodemailer": "^6.4.18", "nodemailer": "^6.4.18",
"oauth2orize": "^1.11.0",
"passport": "0.4.1", "passport": "0.4.1",
"passport-local": "1.0.0", "passport-custom": "^1.1.1",
"passport-http-bearer": "^1.0.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"request": "2.88.2", "request": "2.88.2",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"slug": "^3.5.2", "slug": "^3.5.2",

View file

@ -1,13 +1,20 @@
const passport = require('passport'); const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy; const { Strategy: LocalStrategy } = require('passport-local');
const mongoose = require('mongoose'); const { Strategy: BearerStrategy } = require('passport-http-bearer');
const User = mongoose.model('User'); const { Strategy: JwtStrategy } = require('passport-jwt');
const { Strategy: CustomStrategy } = require('passport-custom');
const { User, AccessToken, RefreshToken } = require('../models');
const secret = require('../config').secret;
passport.use( passport.use(
'usernameAndPassword',
new LocalStrategy( new LocalStrategy(
{ {
usernameField: 'user[email]', usernameField: 'user[email]',
passwordField: 'user[password]', passwordField: 'user[password]',
session: false,
}, },
async function (email, password, done) { async function (email, password, done) {
try { try {
@ -16,10 +23,6 @@ passport.use(
return done(null, false, { errors: { 'email or password': 'is invalid' } }); return done(null, false, { errors: { 'email or password': 'is invalid' } });
} }
if (user.needsEmailValidation) {
return done(null, false, { errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } });
}
return done(null, user); return done(null, user);
} catch (err) { } catch (err) {
done(err); done(err);
@ -27,3 +30,153 @@ passport.use(
}, },
), ),
); );
function getRequestToken(req) {
const authorization = req.headers.authorization;
if (typeof authorization !== 'string') {
return null;
}
const [tokenType, token] = authorization.split(' ');
if (tokenType === 'Token' || tokenType === 'Bearer') {
return token;
}
return null;
}
passport.use(
'jwt',
new JwtStrategy(
{
secretOrKey: secret,
jwtFromRequest: getRequestToken,
algorithms: ['HS256'],
},
async function (token, done) {
try {
// we used to put the user ID into the token directly :(
const {id} = token
const user = await User.findById(id);
return done(null, user || false);
} catch (err) {
return done(err);
}
},
),
);
passport.use(
'accessToken',
new BearerStrategy(async function (token, done) {
try {
const accessToken = await AccessToken.findOne({ token }).populate('user');
if (accessToken && accessToken.user) {
// TODO: scope
return done(null, user, { scope: accessToken.scope });
} else {
return done(null, false);
}
} catch (err) {
return done(err);
}
}),
);
passport.use(
'refreshToken',
new BearerStrategy(async function (token, done) {
try {
const refreshToken = await RefreshToken.findOne({ token }).populate('user');
if (refreshToken && refreshToken.user) {
// TODO: scope
return done(null, user, { scope: 'auth.refresh' });
} else {
return done(null, false);
}
} catch (err) {
return done(err);
}
}),
);
passport.use(
'userId',
new CustomStrategy(async (req, callback) => {
try {
let userId;
const headerToken = getRequestToken(req);
if (headerToken && headerToken.length === 24) {
userId = headerToken;
}
if (!userId) {
const bodyId = req.body && req.body.id;
if (bodyId && bodyId.length === 24) {
userId = bodyId;
}
}
let user;
if (userId) {
user = await User.findById(userId);
}
callback(null, user || false);
} catch (err) {
callback(err);
}
}),
);
/**
* This function creates a middleware that does a passport authentication.
*/
function createMiddleware(strategies, required) {
return (req, res, next) => {
passport.authenticate(strategies, { session: false }, (err, user, info) => {
// If this authentication produced an error, throw it. In a chain of
// multiple strategies, errors are ignored, unless every strategy errors.
if (required && err) {
return next(err);
}
// If you *must* be logged in for this action, require a user.
if (required && !user) {
return res.sendStatus(403);
}
// Regardless of whether login is required, if you're logged in as an
// unverified user, produce an error.
if (user && user.needsEmailValidation) {
return res.status(403).json({ errors: { 'E-Mail-Bestätigung': 'noch nicht erfolgt' } });
}
req.user = user
req.authInfo = info
req.scope = (info && info.scope) || '*'
return next();
})(req, res, next);
};
}
module.exports = {
// these are the standard authentication mechanisms, for when you want user
// information in the route, and either require a login, or don't care
optional: createMiddleware(['jwt', 'accessToken'], false),
required: createMiddleware(['jwt', 'accessToken'], true),
// required to check username and passwort for generating a new token, e.g.
// on the /users/login route, and later on oauth routes
usernameAndPassword: createMiddleware('usernameAndPassword', true),
// will be used to verify a refresh token on the route that will exchange the
// refresh token for a new access token (not in use yet)
refreshToken: createMiddleware('refreshToken', true),
// for track upload, we still allow "userId" for a while
requiredWithUserId: createMiddleware(['jwt', 'accessToken', 'userId'], true),
};

View file

@ -4,16 +4,18 @@ const bodyParser = require('body-parser');
const session = require('express-session'); const session = require('express-session');
const cors = require('cors'); const cors = require('cors');
const errorhandler = require('errorhandler'); const errorhandler = require('errorhandler');
const auth = require('./routes/auth'); const passport = require('passport');
require('./config/passport')
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
// Create global app object // Create global app object
const app = express(); const app = express();
app.use(cors()); app.use(cors());
app.use(auth.getUserIdMiddleware); app.use(passport.initialize());
app.use(auth.loadUserMiddleware);
// Normal express config defaults // Normal express config defaults
app.use(require('morgan')('dev')); app.use(require('morgan')('dev'));

View file

@ -0,0 +1,47 @@
const mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const crypto = require('crypto');
const schema = new mongoose.Schema(
{
token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
expiresAt: { type: Date, required: true },
scope: { type: String, required: true, defaultValue: '*' },
},
{ timestamps: true },
);
schema.plugin(uniqueValidator, { message: 'reused token' });
class AccessToken extends mongoose.Model {
toJSON() {
return {
token: this.token,
expires: this.expires,
};
}
isValid() {
return this.expiresAt < new Date()
}
toHeaderString() {
return 'Bearer ' + this.token;
}
static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
const token = crypto.randomBytes(32).toString('hex');
return new AccessToken({
token,
user,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
});
}
}
mongoose.model(AccessToken, schema);
module.exports = AccessToken;

View file

@ -0,0 +1,49 @@
const mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');
const crypto = require('crypto');
const AccessToken = require('./AccessToken')
const schema = new mongoose.Schema(
{
token: { index: true, type: String, required: true, unique: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
expiresAt: { type: Date, required: false },
scope: { type: String, required: true, defaultValue: '*' },
},
{ timestamps: true },
);
schema.plugin(uniqueValidator, { message: 'reused token' });
class RefreshToken extends mongoose.Model {
toJSON() {
return {
token: this.token,
expires: this.expires,
};
}
isValid() {
return this.expiresAt == null || this.expiresAt < new Date()
}
static generate(user, scope = '*', expiresInSeconds = 24 * 60 * 60) {
const token = crypto.randomBytes(32).toString('hex');
return new RefreshToken({
token,
user,
expiresAt: new Date(new Date().getTime() + 1000 * expiresInSeconds),
scope,
});
}
genererateAccessToken(expiresInSeconds = undefined) {
return AccessToken.generate(this.user, this.scope, expiresInSeconds)
}
}
mongoose.model(RefreshToken, schema);
module.exports = RefreshToken;

6
api/src/models/index.js Normal file
View file

@ -0,0 +1,6 @@
module.exports.AccessToken = require('./AccessToken')
module.exports.Comment = require('./Comment')
module.exports.RefreshToken = require('./RefreshToken')
module.exports.Track = require('./Track')
module.exports.TrackData = require('./TrackData')
module.exports.User = require('./User')

View file

@ -2,7 +2,7 @@ const router = require('express').Router();
const mongoose = require('mongoose'); const mongoose = require('mongoose');
const User = mongoose.model('User'); const User = mongoose.model('User');
const wrapRoute = require('../../_helpers/wrapRoute'); const wrapRoute = require('../../_helpers/wrapRoute');
const auth = require('../auth'); const auth = require('../../config/passport');
// Preload user profile on routes with ':username' // Preload user profile on routes with ':username'
router.param('username', async function (req, res, next, username) { router.param('username', async function (req, res, next, username) {

View file

@ -5,7 +5,7 @@ const Track = mongoose.model('Track');
const Comment = mongoose.model('Comment'); const Comment = mongoose.model('Comment');
const User = mongoose.model('User'); const User = mongoose.model('User');
const busboy = require('connect-busboy'); const busboy = require('connect-busboy');
const auth = require('../auth'); const auth = require('../../config/passport');
const { normalizeUserAgent, buildObsver1 } = require('../../logic/tracks'); const { normalizeUserAgent, buildObsver1 } = require('../../logic/tracks');
const wrapRoute = require('../../_helpers/wrapRoute'); const wrapRoute = require('../../_helpers/wrapRoute');
@ -172,7 +172,7 @@ async function getMultipartOrJsonBody(req, mapJsonBody = (x) => x) {
router.post( router.post(
'/', '/',
auth.required, auth.requiredWithUserId,
busboy(), // parse multipart body busboy(), // parse multipart body
wrapRoute(async (req, res) => { wrapRoute(async (req, res) => {
// Read the whole file into memory. This is not optimal, instead, we should // Read the whole file into memory. This is not optimal, instead, we should

View file

@ -1,7 +1,7 @@
const router = require('express').Router(); const router = require('express').Router();
const passport = require('passport'); const passport = require('passport');
const wrapRoute = require('../../_helpers/wrapRoute'); const wrapRoute = require('../../_helpers/wrapRoute');
const auth = require('../auth'); const auth = require('../../config/passport');
router.get( router.get(
'/user', '/user',
@ -42,27 +42,11 @@ router.put(
}), }),
); );
router.post('/users/login', function (req, res, next) { router.post('/users/login',
if (!req.body.user.email) { auth.usernameAndPassword,
return res.status(422).json({ errors: { email: "can't be blank" } }); wrapRoute((req, res) => {
} return res.json({ user: req.user.toAuthJSON() });
}),
if (!req.body.user.password) { );
return res.status(422).json({ errors: { password: "can't be blank" } });
}
passport.authenticate('local', { session: false }, function (err, user, info) {
if (err) {
return next(err);
}
if (user) {
user.token = user.generateJWT();
return res.json({ user: user.toAuthJSON() });
} else {
return res.status(422).json(info);
}
})(req, res, next);
});
module.exports = router; module.exports = router;

View file

@ -1,4 +1,5 @@
const jwt = require('express-jwt'); const passport = require('passport')
const {LocalStrategy} = require('passport-local')
const secret = require('../config').secret; const secret = require('../config').secret;
const User = require('../models/User'); const User = require('../models/User');
@ -27,19 +28,13 @@ async function getUserIdMiddleware(req, res, next) {
const [tokenType, token] = (authorization && authorization.split(' ')) || []; const [tokenType, token] = (authorization && authorization.split(' ')) || [];
if (tokenType === 'Token' || tokenType === 'Bearer') { if (tokenType === 'Token' || tokenType === 'Bearer') {
// only parse the token as jwt if it looks like one, otherwise we get an error // only parse the token as jwt if it looks like one, otherwise we get an error
return jwtOptional(req, res, next); return jwtOptional(req, res, next);
} else if (tokenType === 'OBSUserId') { } else if (tokenType === 'OBSUserId') {
req.authInfo = { id: token.trim() }; req.authInfo = { id: token.trim() };
next(); next();
} else if (!authorization && req.body && req.body.id && req.body.id.length === 24) {
const user = await User.findById(req.body.id);
if (user) {
req.authInfo = { id: user.id };
req.user = user;
}
next();
} else {
req.authInfo = null; req.authInfo = null;
next(); next();
} }
@ -48,33 +43,3 @@ async function getUserIdMiddleware(req, res, next) {
} }
} }
async function loadUserMiddleware(req, res, next) {
try {
if (req.authInfo && req.authInfo.id) {
req.user = await User.findById(req.authInfo.id);
if (!req.user) {
return res.sendStatus(401);
}
}
next();
} catch (err) {
next(err);
}
}
module.exports = {
required(req, res, next) {
if (!req.authInfo) {
return res.sendStatus(403);
} else {
return next();
}
},
optional(req, res, next) {
return next();
},
getUserIdMiddleware,
loadUserMiddleware,
};