diff --git a/package.json b/package.json index 25348c8e8b..11e0acd1d7 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "daemon": "~1.1.0", "express": "^4.9.5", "express-session": "^1.8.2", + "express-useragent": "0.2.4", "heapdump": "^0.3.0", "html-to-text": "1.5.0", "jimp": "0.2.20", diff --git a/public/language/en_GB/error.json b/public/language/en_GB/error.json index 1625aef3a5..7644960602 100644 --- a/public/language/en_GB/error.json +++ b/public/language/en_GB/error.json @@ -118,5 +118,7 @@ "wrong-login-type-email": "Please use your email to login", "wrong-login-type-username": "Please use your username to login", - "invite-maximum-met": "You have invited the maximum amount of people (%1 out of %2)." + "invite-maximum-met": "You have invited the maximum amount of people (%1 out of %2).", + + "no-session-found": "No login session found!" } diff --git a/public/language/en_GB/global.json b/public/language/en_GB/global.json index 8ef1ff7496..e6f4a35ee6 100644 --- a/public/language/en_GB/global.json +++ b/public/language/en_GB/global.json @@ -107,5 +107,7 @@ "follow": "Follow", "unfollow": "Unfollow", "delete_all": "Delete All", - "map": "Map" + "map": "Map", + "sessions": "Login Sessions", + "ip_address": "IP Address" } diff --git a/public/src/client/account/settings.js b/public/src/client/account/settings.js index 840f498259..04312f2823 100644 --- a/public/src/client/account/settings.js +++ b/public/src/client/account/settings.js @@ -2,7 +2,7 @@ /*global define, socket, app, ajaxify, config*/ -define('forum/account/settings', ['forum/account/header'], function(header) { +define('forum/account/settings', ['forum/account/header', 'components', 'csrf'], function(header, components, csrf) { var AccountSettings = {}; AccountSettings.init = function() { @@ -72,6 +72,9 @@ define('forum/account/settings', ['forum/account/header'], function(header) { $('[data-property="homePageRoute"]').on('change', toggleCustomRoute); toggleCustomRoute(); + + components.get('user/sessions').find('.timeago').timeago(); + prepareSessionRevoking(); }; function toggleCustomRoute() { @@ -83,5 +86,28 @@ define('forum/account/settings', ['forum/account/header'], function(header) { } } + function prepareSessionRevoking() { + components.get('user/sessions').on('click', '[data-action]', function() { + var parentEl = $(this).parents('[data-uuid]'), + uuid = parentEl.attr('data-uuid'); + + if (uuid) { + // This is done via DELETE because a user shouldn't be able to + // revoke his own session! This is what logout is for + $.ajax({ + url: config.relative_path + '/user/' + ajaxify.data.userslug + '/session/' + uuid, + method: 'delete', + headers: { + 'x-csrf-token': csrf.get() + } + }).done(function() { + parentEl.remove(); + }).fail(function(err) { + app.alertError(err.responseText); + }) + } + }); + } + return AccountSettings; }); diff --git a/public/src/modules/helpers.js b/public/src/modules/helpers.js index ed27369c27..ee1a6aed17 100644 --- a/public/src/modules/helpers.js +++ b/public/src/modules/helpers.js @@ -191,6 +191,58 @@ } }; + helpers.userAgentIcons = function(data) { + var icons = ''; + + switch(data.platform) { + case 'Linux': + icons += ''; + break; + case 'Microsoft Windows': + icons += ''; + break; + case 'Mac': + icons += ''; + break; + case 'Android': + icons += ''; + break; + case 'iPad': + icons += ''; + break; + case 'iPod': // intentional fall-through + case 'iPhone': + icons += ''; + break; + default: + icons += ''; + break; + } + + switch(data.browser) { + case 'Chrome': + icons += ''; + break; + case 'Firefox': + icons += ''; + break; + case 'Safari': + icons += ''; + break; + case 'IE': + icons += ''; + break; + case 'Edge': + icons += ''; + break; + default: + icons += ''; + break; + } + + return icons; + } + exports.register = function() { var templates; diff --git a/public/vendor/fontawesome/fonts/FontAwesome.otf b/public/vendor/fontawesome/fonts/FontAwesome.otf index 681bdd4d4c..3ed7f8b48a 100644 Binary files a/public/vendor/fontawesome/fonts/FontAwesome.otf and b/public/vendor/fontawesome/fonts/FontAwesome.otf differ diff --git a/public/vendor/fontawesome/fonts/fontawesome-webfont.eot b/public/vendor/fontawesome/fonts/fontawesome-webfont.eot index a30335d748..9b6afaedc0 100644 Binary files a/public/vendor/fontawesome/fonts/fontawesome-webfont.eot and b/public/vendor/fontawesome/fonts/fontawesome-webfont.eot differ diff --git a/public/vendor/fontawesome/fonts/fontawesome-webfont.svg b/public/vendor/fontawesome/fonts/fontawesome-webfont.svg index 6fd19abcb9..d05688e9e2 100644 --- a/public/vendor/fontawesome/fonts/fontawesome-webfont.svg +++ b/public/vendor/fontawesome/fonts/fontawesome-webfont.svg @@ -1,6 +1,6 @@ - + @@ -219,8 +219,8 @@ - - + + @@ -362,7 +362,7 @@ - + @@ -410,7 +410,7 @@ - + @@ -454,7 +454,7 @@ - + @@ -555,7 +555,7 @@ - + @@ -600,11 +600,11 @@ - - + + - + @@ -621,20 +621,35 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/vendor/fontawesome/fonts/fontawesome-webfont.ttf b/public/vendor/fontawesome/fonts/fontawesome-webfont.ttf index d7994e1308..26dea7951a 100644 Binary files a/public/vendor/fontawesome/fonts/fontawesome-webfont.ttf and b/public/vendor/fontawesome/fonts/fontawesome-webfont.ttf differ diff --git a/public/vendor/fontawesome/fonts/fontawesome-webfont.woff b/public/vendor/fontawesome/fonts/fontawesome-webfont.woff index 6fd4ede0f3..dc35ce3c2c 100644 Binary files a/public/vendor/fontawesome/fonts/fontawesome-webfont.woff and b/public/vendor/fontawesome/fonts/fontawesome-webfont.woff differ diff --git a/public/vendor/fontawesome/fonts/fontawesome-webfont.woff2 b/public/vendor/fontawesome/fonts/fontawesome-webfont.woff2 index 5560193ccc..500e517253 100644 Binary files a/public/vendor/fontawesome/fonts/fontawesome-webfont.woff2 and b/public/vendor/fontawesome/fonts/fontawesome-webfont.woff2 differ diff --git a/public/vendor/fontawesome/less/font-awesome.less b/public/vendor/fontawesome/less/font-awesome.less index e3f89c8f6c..c35d3eeb90 100644 --- a/public/vendor/fontawesome/less/font-awesome.less +++ b/public/vendor/fontawesome/less/font-awesome.less @@ -1,5 +1,5 @@ /*! - * Font Awesome 4.4.0 by @davegandy - http://fontawesome.io - @fontawesome + * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) */ diff --git a/public/vendor/fontawesome/less/icons.less b/public/vendor/fontawesome/less/icons.less index 6ebe9669e8..ca60abd7e1 100644 --- a/public/vendor/fontawesome/less/icons.less +++ b/public/vendor/fontawesome/less/icons.less @@ -675,3 +675,23 @@ .@{fa-css-prefix}-vimeo:before { content: @fa-var-vimeo; } .@{fa-css-prefix}-black-tie:before { content: @fa-var-black-tie; } .@{fa-css-prefix}-fonticons:before { content: @fa-var-fonticons; } +.@{fa-css-prefix}-reddit-alien:before { content: @fa-var-reddit-alien; } +.@{fa-css-prefix}-edge:before { content: @fa-var-edge; } +.@{fa-css-prefix}-credit-card-alt:before { content: @fa-var-credit-card-alt; } +.@{fa-css-prefix}-codiepie:before { content: @fa-var-codiepie; } +.@{fa-css-prefix}-modx:before { content: @fa-var-modx; } +.@{fa-css-prefix}-fort-awesome:before { content: @fa-var-fort-awesome; } +.@{fa-css-prefix}-usb:before { content: @fa-var-usb; } +.@{fa-css-prefix}-product-hunt:before { content: @fa-var-product-hunt; } +.@{fa-css-prefix}-mixcloud:before { content: @fa-var-mixcloud; } +.@{fa-css-prefix}-scribd:before { content: @fa-var-scribd; } +.@{fa-css-prefix}-pause-circle:before { content: @fa-var-pause-circle; } +.@{fa-css-prefix}-pause-circle-o:before { content: @fa-var-pause-circle-o; } +.@{fa-css-prefix}-stop-circle:before { content: @fa-var-stop-circle; } +.@{fa-css-prefix}-stop-circle-o:before { content: @fa-var-stop-circle-o; } +.@{fa-css-prefix}-shopping-bag:before { content: @fa-var-shopping-bag; } +.@{fa-css-prefix}-shopping-basket:before { content: @fa-var-shopping-basket; } +.@{fa-css-prefix}-hashtag:before { content: @fa-var-hashtag; } +.@{fa-css-prefix}-bluetooth:before { content: @fa-var-bluetooth; } +.@{fa-css-prefix}-bluetooth-b:before { content: @fa-var-bluetooth-b; } +.@{fa-css-prefix}-percent:before { content: @fa-var-percent; } diff --git a/public/vendor/fontawesome/less/variables.less b/public/vendor/fontawesome/less/variables.less index 0c161af192..34d4041cc7 100644 --- a/public/vendor/fontawesome/less/variables.less +++ b/public/vendor/fontawesome/less/variables.less @@ -4,9 +4,9 @@ @fa-font-path: "./vendor/fontawesome/fonts"; @fa-font-size-base: 14px; @fa-line-height-base: 1; -//@fa-font-path: "//netdna.bootstrapcdn.com/font-awesome/4.4.0/fonts"; // for referencing Bootstrap CDN font files directly +//@fa-font-path: "//netdna.bootstrapcdn.com/font-awesome/4.5.0/fonts"; // for referencing Bootstrap CDN font files directly @fa-css-prefix: fa; -@fa-version: "4.4.0"; +@fa-version: "4.5.0"; @fa-border-color: #eee; @fa-inverse: #fff; @fa-li-width: (30em / 14); @@ -86,6 +86,8 @@ @fa-var-bitbucket-square: "\f172"; @fa-var-bitcoin: "\f15a"; @fa-var-black-tie: "\f27e"; +@fa-var-bluetooth: "\f293"; +@fa-var-bluetooth-b: "\f294"; @fa-var-bold: "\f032"; @fa-var-bolt: "\f0e7"; @fa-var-bomb: "\f1e2"; @@ -164,6 +166,7 @@ @fa-var-code: "\f121"; @fa-var-code-fork: "\f126"; @fa-var-codepen: "\f1cb"; +@fa-var-codiepie: "\f284"; @fa-var-coffee: "\f0f4"; @fa-var-cog: "\f013"; @fa-var-cogs: "\f085"; @@ -182,6 +185,7 @@ @fa-var-copyright: "\f1f9"; @fa-var-creative-commons: "\f25e"; @fa-var-credit-card: "\f09d"; +@fa-var-credit-card-alt: "\f283"; @fa-var-crop: "\f125"; @fa-var-crosshairs: "\f05b"; @fa-var-css3: "\f13c"; @@ -204,6 +208,7 @@ @fa-var-dribbble: "\f17d"; @fa-var-dropbox: "\f16b"; @fa-var-drupal: "\f1a9"; +@fa-var-edge: "\f282"; @fa-var-edit: "\f044"; @fa-var-eject: "\f052"; @fa-var-ellipsis-h: "\f141"; @@ -273,6 +278,7 @@ @fa-var-folder-open-o: "\f115"; @fa-var-font: "\f031"; @fa-var-fonticons: "\f280"; +@fa-var-fort-awesome: "\f286"; @fa-var-forumbee: "\f211"; @fa-var-forward: "\f04e"; @fa-var-foursquare: "\f180"; @@ -319,6 +325,7 @@ @fa-var-hand-scissors-o: "\f257"; @fa-var-hand-spock-o: "\f259"; @fa-var-hand-stop-o: "\f256"; +@fa-var-hashtag: "\f292"; @fa-var-hdd-o: "\f0a0"; @fa-var-header: "\f1dc"; @fa-var-headphones: "\f025"; @@ -418,8 +425,10 @@ @fa-var-minus-circle: "\f056"; @fa-var-minus-square: "\f146"; @fa-var-minus-square-o: "\f147"; +@fa-var-mixcloud: "\f289"; @fa-var-mobile: "\f10b"; @fa-var-mobile-phone: "\f10b"; +@fa-var-modx: "\f285"; @fa-var-money: "\f0d6"; @fa-var-moon-o: "\f186"; @fa-var-mortar-board: "\f19d"; @@ -446,11 +455,14 @@ @fa-var-paragraph: "\f1dd"; @fa-var-paste: "\f0ea"; @fa-var-pause: "\f04c"; +@fa-var-pause-circle: "\f28b"; +@fa-var-pause-circle-o: "\f28c"; @fa-var-paw: "\f1b0"; @fa-var-paypal: "\f1ed"; @fa-var-pencil: "\f040"; @fa-var-pencil-square: "\f14b"; @fa-var-pencil-square-o: "\f044"; +@fa-var-percent: "\f295"; @fa-var-phone: "\f095"; @fa-var-phone-square: "\f098"; @fa-var-photo: "\f03e"; @@ -472,6 +484,7 @@ @fa-var-plus-square-o: "\f196"; @fa-var-power-off: "\f011"; @fa-var-print: "\f02f"; +@fa-var-product-hunt: "\f288"; @fa-var-puzzle-piece: "\f12e"; @fa-var-qq: "\f1d6"; @fa-var-qrcode: "\f029"; @@ -484,6 +497,7 @@ @fa-var-rebel: "\f1d0"; @fa-var-recycle: "\f1b8"; @fa-var-reddit: "\f1a1"; +@fa-var-reddit-alien: "\f281"; @fa-var-reddit-square: "\f1a2"; @fa-var-refresh: "\f021"; @fa-var-registered: "\f25d"; @@ -508,6 +522,7 @@ @fa-var-safari: "\f267"; @fa-var-save: "\f0c7"; @fa-var-scissors: "\f0c4"; +@fa-var-scribd: "\f28a"; @fa-var-search: "\f002"; @fa-var-search-minus: "\f010"; @fa-var-search-plus: "\f00e"; @@ -525,6 +540,8 @@ @fa-var-shield: "\f132"; @fa-var-ship: "\f21a"; @fa-var-shirtsinbulk: "\f214"; +@fa-var-shopping-bag: "\f290"; +@fa-var-shopping-basket: "\f291"; @fa-var-shopping-cart: "\f07a"; @fa-var-sign-in: "\f090"; @fa-var-sign-out: "\f08b"; @@ -572,6 +589,8 @@ @fa-var-sticky-note: "\f249"; @fa-var-sticky-note-o: "\f24a"; @fa-var-stop: "\f04d"; +@fa-var-stop-circle: "\f28d"; +@fa-var-stop-circle-o: "\f28e"; @fa-var-street-view: "\f21d"; @fa-var-strikethrough: "\f0cc"; @fa-var-stumbleupon: "\f1a4"; @@ -642,6 +661,7 @@ @fa-var-unlock-alt: "\f13e"; @fa-var-unsorted: "\f0dc"; @fa-var-upload: "\f093"; +@fa-var-usb: "\f287"; @fa-var-usd: "\f155"; @fa-var-user: "\f007"; @fa-var-user-md: "\f0f0"; diff --git a/src/controllers/accounts.js b/src/controllers/accounts.js index 65116b26da..789393703a 100644 --- a/src/controllers/accounts.js +++ b/src/controllers/accounts.js @@ -8,7 +8,8 @@ var accountsController = { follow: require('./accounts/follow'), posts: require('./accounts/posts'), notifications: require('./accounts/notifications'), - chats: require('./accounts/chats') + chats: require('./accounts/chats'), + session: require('./accounts/session') }; module.exports = accountsController; diff --git a/src/controllers/accounts/session.js b/src/controllers/accounts/session.js new file mode 100644 index 0000000000..97529df09b --- /dev/null +++ b/src/controllers/accounts/session.js @@ -0,0 +1,43 @@ +'use strict'; + +var async = require('async'), + + user = require('../../user'), + db = require('../../database'); + +var sessionController = {}; + +sessionController.revoke = function(req, res, next) { + if (!req.params.hasOwnProperty('uuid')) { + return next(); + } + + var _id; + + async.waterfall([ + async.apply(db.getObjectField, 'sessionUUID:sessionId', req.params.uuid), + function(sessionId, next) { + if (!sessionId) { + return next(new Error('[[error:no-session-found]]')); + } + + _id = sessionId; + db.isSortedSetMember('uid:' + req.uid + ':sessions', sessionId, next) + }, + function(isMember, next) { + if (isMember) { + user.auth.revokeSession(_id, req.uid, next); + } else { + next(new Error('[[error:no-session-found]]')); + } + } + ], function(err) { + if (err) { + return res.status(500).send(err.message); + } else { + return res.sendStatus(200); + } + }); +}; + +module.exports = sessionController; \ No newline at end of file diff --git a/src/controllers/accounts/settings.js b/src/controllers/accounts/settings.js index 1109f58813..c56fd10c1e 100644 --- a/src/controllers/accounts/settings.js +++ b/src/controllers/accounts/settings.js @@ -83,7 +83,8 @@ settingsController.get = function(req, res, callback) { next(null, data.routes); }); }); - } + }, + sessions: async.apply(user.auth.getSessions, userData.uid, req.sessionID) }, next); }, function(results, next) { @@ -91,6 +92,7 @@ settingsController.get = function(req, res, callback) { userData.languages = results.languages; userData.userGroups = results.userGroups[0]; userData.homePageRoutes = results.homePageRoutes; + userData.sessions = results.sessions; plugins.fireHook('filter:user.customSettings', {settings: results.settings, customSettings: [], uid: req.uid}, next); }, function(data, next) { diff --git a/src/controllers/authentication.js b/src/controllers/authentication.js index 1f5d049dc3..cabb25b79d 100644 --- a/src/controllers/authentication.js +++ b/src/controllers/authentication.js @@ -5,6 +5,7 @@ var async = require('async'), passport = require('passport'), nconf = require('nconf'), validator = require('validator'), + _ = require('underscore'), db = require('../database'), meta = require('../meta'), @@ -176,8 +177,27 @@ function continueLogin(req, res, next) { if (err) { return res.status(403).send(err.message); } + if (userData.uid) { + var uuid = utils.generateUUID(); + req.session.meta = {}; + + // Associate IP used during login with user account user.logIP(userData.uid, req.ip); + req.session.meta.ip = req.ip; + + // Associate metadata retrieved via user-agent + req.session.meta = _.extend(req.session.meta, { + uuid: uuid, + datetime: Date.now(), + platform: req.useragent.platform, + browser: req.useragent.browser, + version: req.useragent.version + }); + + // Associate login session with user + user.auth.addSession(userData.uid, req.sessionID); + db.setObjectField('sessionUUID:sessionId', uuid, req.sessionID); plugins.fireHook('action:user.loggedIn', userData.uid); } @@ -254,7 +274,7 @@ authenticationController.localLogin = function(req, username, password, next) { authenticationController.logout = function(req, res, next) { if (req.user && parseInt(req.user.uid, 10) > 0 && req.sessionID) { var uid = parseInt(req.user.uid, 10); - db.sessionStore.destroy(req.sessionID, function(err) { + user.auth.revokeSession(req.sessionID, uid, function(err) { if (err) { return next(err); } diff --git a/src/middleware/index.js b/src/middleware/index.js index e61bae0afe..d9d0aee089 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -13,7 +13,8 @@ var meta = require('../meta'), cookieParser = require('cookie-parser'), compression = require('compression'), favicon = require('serve-favicon'), - session = require('express-session'); + session = require('express-session'), + useragent = require('express-useragent'); var middleware = {}; @@ -47,6 +48,7 @@ module.exports = function(app) { app.use(bodyParser.urlencoded({extended: true})); app.use(bodyParser.json()); app.use(cookieParser()); + app.use(useragent.express()); var cookie = { maxAge: 1000 * 60 * 60 * 24 * (parseInt(meta.config.loginDays, 10) || 14) diff --git a/src/routes/accounts.js b/src/routes/accounts.js index e3a753c2fc..aa6f62bb89 100644 --- a/src/routes/accounts.js +++ b/src/routes/accounts.js @@ -22,6 +22,8 @@ module.exports = function (app, middleware, controllers) { setupPageRoute(app, '/user/:userslug/edit/password', middleware, accountMiddlewares, controllers.accounts.edit.password); setupPageRoute(app, '/user/:userslug/settings', middleware, accountMiddlewares, controllers.accounts.settings.get); + app.delete('/user/:userslug/session/:uuid', accountMiddlewares, controllers.accounts.session.revoke); + setupPageRoute(app, '/notifications', middleware, [middleware.authenticate], controllers.accounts.notifications.get); setupPageRoute(app, '/chats/:roomid?', middleware, [middleware.authenticate], controllers.accounts.chats.get); }; diff --git a/src/user/auth.js b/src/user/auth.js index 48f0c8fc2c..567e4b2baa 100644 --- a/src/user/auth.js +++ b/src/user/auth.js @@ -1,6 +1,7 @@ 'use strict'; var async = require('async'), + winston = require('winston'), db = require('../database'), meta = require('../meta'), events = require('../events'); @@ -58,4 +59,81 @@ module.exports = function(User) { async.apply(db.delete, 'lockout:' + uid) ], callback); }; + + User.auth.getSessions = function(uid, curSessionId, callback) { + var _sids; + + // curSessionId is optional + if (arguments.length === 2 && typeof curSessionId === 'function') { + callback = curSessionId; + curSessionId = undefined; + } + + async.waterfall([ + async.apply(db.getSortedSetRange, 'uid:' + uid + ':sessions', 0, -1), + function(sids, next) { + _sids = sids; + async.map(sids, db.sessionStore.get.bind(db.sessionStore), next); + }, + function(sessions, next) { + sessions = sessions.map(function(sessionObj, idx) { + sessionObj.meta.current = curSessionId === _sids[idx]; + return sessionObj; + }); + + // Revoke any sessions that have expired, return filtered list + var expiredSids = [], + expired; + + sessions = sessions.filter(function(sessionObj, idx) { + expired = !sessionObj || !sessionObj.hasOwnProperty('passport') + || !sessionObj.passport.hasOwnProperty('user') + || parseInt(sessionObj.passport.user, 10) !== parseInt(uid, 10); + + if (expired) { + expiredSids.push(_sids[idx]); + } + + return !expired; + }, []) + + async.each(expiredSids, function(sid, next) { + User.auth.revokeSession(sid, uid, next); + }, function(err) { + next(null, sessions); + }); + } + ], function(err, sessions) { + callback(err, sessions ? sessions.map(function(sessObj) { + sessObj.meta.datetimeISO = new Date(sessObj.meta.datetime).toISOString(); + return sessObj.meta; + }) : undefined); + }); + }; + + User.auth.addSession = function(uid, sessionId, callback) { + callback = callback || function() {}; + db.sortedSetAdd('uid:' + uid + ':sessions', Date.now(), sessionId, callback); + }; + + User.auth.revokeSession = function(sessionId, uid, callback) { + winston.verbose('[user.auth] Revoking session ' + sessionId + ' for user ' + uid); + + db.sessionStore.get(sessionId, function(err, sessionObj) { + async.parallel([ + async.apply(db.deleteObjectField, 'sessionUUID:sessionId', sessionObj.meta.uuid), + async.apply(db.sortedSetRemove, 'uid:' + uid + ':sessions', sessionId), + async.apply(db.sessionStore.destroy.bind(db.sessionStore), sessionId) + ], callback); + }); + }; + + User.auth.revokeAllSessions = function(uid, callback) { + async.waterfall([ + async.apply(db.getSortedSetRange, 'uid:' + uid + ':sessions', 0, -1), + function(sids, next) { + async.each(sids, User.auth.revokeSession, next); + } + ], callback); + }; }; \ No newline at end of file