diff --git a/api/package-lock.json b/api/package-lock.json index f74fcf4..e95c0d2 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -6243,6 +6243,16 @@ "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", "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": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6471,6 +6481,31 @@ "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", @@ -8566,6 +8601,11 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.3.tgz", diff --git a/api/package.json b/api/package.json index 3abe063..d40f21b 100644 --- a/api/package.json +++ b/api/package.json @@ -41,8 +41,12 @@ "mongoose-unique-validator": "2.0.3", "morgan": "1.10.0", "nodemailer": "^6.4.18", + "oauth2orize": "^1.11.0", "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", "sanitize-filename": "^1.6.3", "slug": "^3.5.2", diff --git a/api/src/config/passport.js b/api/src/config/passport.js index 5e11f0b..8d97068 100644 --- a/api/src/config/passport.js +++ b/api/src/config/passport.js @@ -1,13 +1,20 @@ const passport = require('passport'); -const LocalStrategy = require('passport-local').Strategy; -const mongoose = require('mongoose'); -const User = mongoose.model('User'); +const { Strategy: LocalStrategy } = require('passport-local'); +const { Strategy: BearerStrategy } = require('passport-http-bearer'); +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( + 'usernameAndPassword', new LocalStrategy( { usernameField: 'user[email]', passwordField: 'user[password]', + session: false, }, async function (email, password, done) { try { @@ -16,10 +23,6 @@ passport.use( 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); } catch (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), +}; diff --git a/api/src/index.js b/api/src/index.js index 2bb124d..fc371a6 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -4,16 +4,18 @@ const bodyParser = require('body-parser'); const session = require('express-session'); const cors = require('cors'); const errorhandler = require('errorhandler'); -const auth = require('./routes/auth'); +const passport = require('passport'); + +require('./config/passport') const isProduction = process.env.NODE_ENV === 'production'; // Create global app object const app = express(); + app.use(cors()); -app.use(auth.getUserIdMiddleware); -app.use(auth.loadUserMiddleware); +app.use(passport.initialize()); // Normal express config defaults app.use(require('morgan')('dev')); diff --git a/api/src/models/AccessToken.js b/api/src/models/AccessToken.js new file mode 100644 index 0000000..9cb4552 --- /dev/null +++ b/api/src/models/AccessToken.js @@ -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; diff --git a/api/src/models/RefreshToken.js b/api/src/models/RefreshToken.js new file mode 100644 index 0000000..f57b27e --- /dev/null +++ b/api/src/models/RefreshToken.js @@ -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; diff --git a/api/src/models/index.js b/api/src/models/index.js new file mode 100644 index 0000000..640fda2 --- /dev/null +++ b/api/src/models/index.js @@ -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') diff --git a/api/src/routes/api/profiles.js b/api/src/routes/api/profiles.js index d086595..9d87766 100644 --- a/api/src/routes/api/profiles.js +++ b/api/src/routes/api/profiles.js @@ -2,7 +2,7 @@ const router = require('express').Router(); const mongoose = require('mongoose'); const User = mongoose.model('User'); const wrapRoute = require('../../_helpers/wrapRoute'); -const auth = require('../auth'); +const auth = require('../../config/passport'); // Preload user profile on routes with ':username' router.param('username', async function (req, res, next, username) { diff --git a/api/src/routes/api/tracks.js b/api/src/routes/api/tracks.js index 37bf193..4bc7a00 100644 --- a/api/src/routes/api/tracks.js +++ b/api/src/routes/api/tracks.js @@ -5,7 +5,7 @@ const Track = mongoose.model('Track'); const Comment = mongoose.model('Comment'); const User = mongoose.model('User'); const busboy = require('connect-busboy'); -const auth = require('../auth'); +const auth = require('../../config/passport'); const { normalizeUserAgent, buildObsver1 } = require('../../logic/tracks'); const wrapRoute = require('../../_helpers/wrapRoute'); @@ -172,7 +172,7 @@ async function getMultipartOrJsonBody(req, mapJsonBody = (x) => x) { router.post( '/', - auth.required, + auth.requiredWithUserId, busboy(), // parse multipart body wrapRoute(async (req, res) => { // Read the whole file into memory. This is not optimal, instead, we should diff --git a/api/src/routes/api/users.js b/api/src/routes/api/users.js index 07a19f3..3138f68 100644 --- a/api/src/routes/api/users.js +++ b/api/src/routes/api/users.js @@ -1,7 +1,7 @@ const router = require('express').Router(); const passport = require('passport'); const wrapRoute = require('../../_helpers/wrapRoute'); -const auth = require('../auth'); +const auth = require('../../config/passport'); router.get( '/user', @@ -42,27 +42,11 @@ router.put( }), ); -router.post('/users/login', function (req, res, next) { - if (!req.body.user.email) { - return res.status(422).json({ errors: { email: "can't be blank" } }); - } - - 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); -}); +router.post('/users/login', + auth.usernameAndPassword, + wrapRoute((req, res) => { + return res.json({ user: req.user.toAuthJSON() }); + }), +); module.exports = router; diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index 9e5a6ea..9c6d486 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -1,4 +1,5 @@ -const jwt = require('express-jwt'); +const passport = require('passport') +const {LocalStrategy} = require('passport-local') const secret = require('../config').secret; const User = require('../models/User'); @@ -27,19 +28,13 @@ async function getUserIdMiddleware(req, res, next) { const [tokenType, token] = (authorization && authorization.split(' ')) || []; if (tokenType === 'Token' || tokenType === 'Bearer') { + // only parse the token as jwt if it looks like one, otherwise we get an error return jwtOptional(req, res, next); + } else if (tokenType === 'OBSUserId') { req.authInfo = { id: token.trim() }; 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; 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, -};