From 1d26a473e740d2a207046ccaabbbb6fb46d7d7b4 Mon Sep 17 00:00:00 2001 From: Bianca Nenciu Date: Tue, 9 Oct 2018 17:21:41 +0300 Subject: [PATCH] FEATURE: Show "Recently used devices" in user preferences (#6335) * FEATURE: Added MaxMindDb to resolve IP information. * FEATURE: Added browser detection based on user agent. * FEATURE: Added recently used devices in user preferences. * DEV: Added acceptance test for recently used devices. * UX: Do not show 'Show more' button if there aren't more tokens. * DEV: Fix unit tests. * DEV: Make changes after code review. * Add more detailed unit tests. * Improve logging messages. * Minor coding style fixes. * DEV: Use DropdownSelectBoxComponent and run Prettier. * DEV: Fix unit tests. --- Gemfile | 1 + Gemfile.lock | 2 + .../components/auth-token-dropdown.es6 | 40 +++++++++ .../discourse/controllers/auth-token.js.es6 | 41 +++++++++ .../controllers/preferences/account.js.es6 | 36 +++++++- .../discourse/templates/modal/auth-token.hbs | 33 +++++++ .../templates/preferences/account.hbs | 42 +++++++++ .../stylesheets/common/base/discourse.scss | 87 +++++++++++-------- app/controllers/application_controller.rb | 2 +- app/controllers/posts_controller.rb | 23 +++-- app/controllers/users_controller.rb | 6 +- .../concerns/user_auth_tokens_mixin.rb | 73 +++++++--------- app/serializers/post_serializer.rb | 10 ++- app/serializers/user_auth_token_serializer.rb | 17 ++++ app/serializers/user_serializer.rb | 5 +- config/locales/client.en.yml | 22 ++--- config/locales/server.en.yml | 34 +++++--- config/routes.rb | 1 + lib/browser_detection.rb | 60 +++++++++++++ lib/discourse_ip_info.rb | 46 ++++++++++ lib/guardian.rb | 4 +- lib/tasks/maxminddb.rake | 22 +++++ spec/lib/browser_detection_spec.rb | 37 ++++++++ spec/requests/posts_controller_spec.rb | 14 +++ spec/requests/users_controller_spec.rb | 26 ++++++ spec/support/helpers.rb | 2 +- .../acceptance/preferences-test.js.es6 | 39 +++++++++ .../javascripts/fixtures/user_fixtures.js.es6 | 40 ++++++++- 28 files changed, 648 insertions(+), 117 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/auth-token-dropdown.es6 create mode 100644 app/assets/javascripts/discourse/controllers/auth-token.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/modal/auth-token.hbs create mode 100644 lib/browser_detection.rb create mode 100644 lib/discourse_ip_info.rb create mode 100644 lib/tasks/maxminddb.rake create mode 100644 spec/lib/browser_detection_spec.rb diff --git a/Gemfile b/Gemfile index 3d20436eabe..dd079ee8294 100644 --- a/Gemfile +++ b/Gemfile @@ -194,3 +194,4 @@ end gem 'webpush', require: false gem 'colored2', require: false +gem 'maxminddb' diff --git a/Gemfile.lock b/Gemfile.lock index e4102ed8882..bdf24bd5eff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -191,6 +191,7 @@ GEM lru_redux (1.1.0) mail (2.7.1.rc1) mini_mime (>= 0.1.1) + maxminddb (0.1.21) memory_profiler (0.9.12) message_bus (2.1.5) rack (>= 1.1.3) @@ -488,6 +489,7 @@ DEPENDENCIES logster lru_redux mail (= 2.7.1.rc1) + maxminddb memory_profiler message_bus mini_mime diff --git a/app/assets/javascripts/discourse/components/auth-token-dropdown.es6 b/app/assets/javascripts/discourse/components/auth-token-dropdown.es6 new file mode 100644 index 00000000000..ecb72e3e583 --- /dev/null +++ b/app/assets/javascripts/discourse/components/auth-token-dropdown.es6 @@ -0,0 +1,40 @@ +import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; + +export default DropdownSelectBoxComponent.extend({ + classNames: ["auth-token-dropdown"], + headerIcon: "wrench", + allowInitialValueMutation: false, + showFullTitle: false, + + computeContent() { + const content = [ + { + id: "notYou", + icon: "user-times", + name: I18n.t("user.auth_tokens.not_you"), + description: "" + }, + { + id: "logOut", + icon: "sign-out", + name: I18n.t("user.log_out"), + description: "" + } + ]; + + return content; + }, + + actions: { + onSelect(id) { + switch (id) { + case "notYou": + this.sendAction("showToken", this.get("token")); + break; + case "logOut": + this.sendAction("revokeAuthToken", this.get("token")); + break; + } + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/auth-token.js.es6 b/app/assets/javascripts/discourse/controllers/auth-token.js.es6 new file mode 100644 index 00000000000..88ca16b4065 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/auth-token.js.es6 @@ -0,0 +1,41 @@ +import ModalFunctionality from "discourse/mixins/modal-functionality"; +import { ajax } from "discourse/lib/ajax"; +import { userPath } from "discourse/lib/url"; + +export default Ember.Controller.extend(ModalFunctionality, { + expanded: false, + + onShow() { + ajax( + userPath(`${this.get("currentUser.username_lower")}/activity.json`) + ).then(posts => { + if (posts.length > 0) { + this.set("latest_post", posts[0]); + } + }); + }, + + actions: { + toggleExpanded() { + this.set("expanded", !this.get("expanded")); + }, + + highlightSecure() { + this.send("closeModal"); + + Ember.run.next(() => { + const $prefPasswordDiv = $(".pref-password"); + + $prefPasswordDiv.addClass("highlighted"); + $prefPasswordDiv.on("animationend", () => + $prefPasswordDiv.removeClass("highlighted") + ); + + window.scrollTo({ + top: $prefPasswordDiv.offset().top, + behavior: "smooth" + }); + }); + } + } +}); diff --git a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 index b91f0e66ffa..ca3fe8e7eee 100644 --- a/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 +++ b/app/assets/javascripts/discourse/controllers/preferences/account.js.es6 @@ -9,6 +9,9 @@ import { findAll } from "discourse/models/login-method"; import { ajax } from "discourse/lib/ajax"; import { userPath } from "discourse/lib/url"; +// Number of tokens shown by default. +const DEFAULT_AUTH_TOKENS_COUNT = 2; + export default Ember.Controller.extend( CanCheckEmails, PreferencesTabController, @@ -23,6 +26,8 @@ export default Ember.Controller.extend( passwordProgress: null, + showAllAuthTokens: false, + cannotDeleteAccount: Em.computed.not("currentUser.can_delete_account"), deleteDisabled: Em.computed.or( "model.isSaving", @@ -99,6 +104,22 @@ export default Ember.Controller.extend( ); }, + @computed("showAllAuthTokens", "model.user_auth_tokens") + authTokens(showAllAuthTokens, tokens) { + tokens.sort( + (a, b) => (a.is_active ? -1 : b.is_active ? 1 : a.seen_at < b.seen_at) + ); + + return showAllAuthTokens + ? tokens + : tokens.slice(0, DEFAULT_AUTH_TOKENS_COUNT); + }, + + @computed("model.user_auth_tokens") + canShowAllAuthTokens(tokens) { + return tokens.length > DEFAULT_AUTH_TOKENS_COUNT; + }, + actions: { save() { this.set("saved", false); @@ -200,19 +221,26 @@ export default Ember.Controller.extend( }); }, - toggleToken(token) { - Ember.set(token, "visible", !token.visible); + toggleShowAllAuthTokens() { + this.set("showAllAuthTokens", !this.get("showAllAuthTokens")); }, - revokeAuthToken() { + revokeAuthToken(token) { ajax( userPath( `${this.get("model.username_lower")}/preferences/revoke-auth-token` ), - { type: "POST" } + { + type: "POST", + data: token ? { token_id: token.id } : {} + } ); }, + showToken(token) { + showModal("auth-token", { model: token }); + }, + connectAccount(method) { method.doLogin(); } diff --git a/app/assets/javascripts/discourse/templates/modal/auth-token.hbs b/app/assets/javascripts/discourse/templates/modal/auth-token.hbs new file mode 100644 index 00000000000..7a9cd6ab090 --- /dev/null +++ b/app/assets/javascripts/discourse/templates/modal/auth-token.hbs @@ -0,0 +1,33 @@ +{{#d-modal-body title="user.auth_tokens.was_this_you"}} +
+

{{i18n 'user.auth_tokens.was_this_you_description'}}

+

{{{i18n 'user.second_factor.extended_description'}}}

+
+ +
+

{{i18n 'user.auth_tokens.details'}}

+

{{d-icon "clock-o"}} {{format-date model.seen_at}}

+

{{d-icon "map-marker"}} {{model.location}}

+

{{d-icon model.icon}} {{i18n "user.auth_tokens.browser_and_device" browser=model.browser device=model.device}}

+
+ + {{#if latest_post}} +
+

+ {{i18n 'user.auth_tokens.latest_post'}} + {{d-icon (if expanded "caret-up" "caret-down")}} +

+ + {{#if expanded}} +
{{{latest_post.cooked}}}
+ {{else}} +
{{{latest_post.excerpt}}}
+ {{/if}} +
+ {{/if}} +{{/d-modal-body}} + + diff --git a/app/assets/javascripts/discourse/templates/preferences/account.hbs b/app/assets/javascripts/discourse/templates/preferences/account.hbs index 84b58b05c7f..b6985ba8d3a 100644 --- a/app/assets/javascripts/discourse/templates/preferences/account.hbs +++ b/app/assets/javascripts/discourse/templates/preferences/account.hbs @@ -51,6 +51,48 @@ {{/if}} +{{#if canCheckEmails}} +
+ + +
+ {{#each authTokens as |token|}} +
+
{{d-icon token.icon}}
+ {{#unless token.is_active}} + {{auth-token-dropdown token=token + revokeAuthToken=(action "revokeAuthToken") + showToken=(action "showToken")}} + {{/unless}} +
+ {{token.device}}{{token.location}} +
+
+ {{token.browser}} | + {{#if token.is_active}} + {{i18n 'user.auth_tokens.active'}} + {{else}} + {{format-date token.seen_at}} + {{/if}} +
+
+ {{/each}} +
+ + {{#if canShowAllAuthTokens}} + + {{#if showAllAuthTokens}} + {{d-icon "caret-up"}} {{i18n 'user.auth_tokens.show_few'}} + {{else}} + {{d-icon "caret-down"}} {{i18n 'user.auth_tokens.show_all' count=model.user_auth_tokens.length}} + {{/if}} + + {{/if}} + + {{d-icon "sign-out"}} {{i18n 'user.auth_tokens.log_out_all'}} +
+{{/if}} + {{#if canChangePassword}}
diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss index f49a959d000..c7540f02543 100644 --- a/app/assets/stylesheets/common/base/discourse.scss +++ b/app/assets/stylesheets/common/base/discourse.scss @@ -538,6 +538,11 @@ select { text-align: center; } } + + &.highlighted { + animation: background-fade-highlight 2.5s ease-out; + background-color: dark-light-choose($highlight-low, $highlight); + } } .control-label { @@ -609,49 +614,57 @@ select { } .pref-auth-tokens { - .control-label { - display: inline-block; - } - .row { + border-bottom: 1px solid #ddd; margin: 5px 0px; - } + padding-bottom: 5px; - .muted { - color: $primary-medium; - } - - .perf-auth-token { - background-color: $primary-very-low; - color: $primary; - display: block; - padding: 5px; - margin-bottom: 10px; - } - - .auth-token-summary { - padding: 0px 10px; - - .auth-token-label, - .auth-token-value { - font-size: 1.2em; - margin-top: 5px; + &:last-child { + border-bottom: 0; } } - .auth-token-details { - background: $secondary; - padding: 5px 10px; - margin: 10px 5px 5px 5px; - - .auth-token-label { - color: $primary-medium; - } - } - - .auth-token-label, - .auth-token-value { + .auth-token-icon { + font-size: 2.25em; float: left; - width: 50%; + margin-right: 10px; + } + + .auth-token-first { + font-size: 1.1em; + + .auth-token-device { + font-weight: bold; + } + } + + .auth-token-second { + color: $primary-medium; + + .active { + color: $success; + font-weight: bold; + } + } + + .auth-token-dropdown { + float: right; + + .btn, + .btn:hover { + background: transparent; + + .d-icon { + color: $primary; + } + } + } + + .dropdown-menu { + width: 120px; + + & .icon { + margin-top: auto; + } } } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ff5abffe881..20133fb1ae0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -412,7 +412,7 @@ class ApplicationController < ActionController::Base end def guardian - @guardian ||= Guardian.new(current_user) + @guardian ||= Guardian.new(current_user, request) end def current_homepage diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 9719d769386..eb30dd808cb 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -123,11 +123,24 @@ class PostsController < ApplicationController posts = posts.reject { |post| !guardian.can_see?(post) || post.topic.blank? } - @posts = posts - @title = "#{SiteSetting.title} - #{I18n.t("rss_description.user_posts", username: user.username)}" - @link = "#{Discourse.base_url}/u/#{user.username}/activity" - @description = I18n.t("rss_description.user_posts", username: user.username) - render 'posts/latest', formats: [:rss] + respond_to do |format| + format.rss do + @posts = posts + @title = "#{SiteSetting.title} - #{I18n.t("rss_description.user_posts", username: user.username)}" + @link = "#{Discourse.base_url}/u/#{user.username}/activity" + @description = I18n.t("rss_description.user_posts", username: user.username) + render 'posts/latest', formats: [:rss] + end + + format.json do + render_json_dump(serialize_data(posts, + PostSerializer, + scope: guardian, + add_excerpt: true) + ) + end + end + end def cooked diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 44df9dad04d..12ada62ff06 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1108,7 +1108,11 @@ class UsersController < ApplicationController user = fetch_user_from_params guardian.ensure_can_edit!(user) - UserAuthToken.where(user_id: user.id).each(&:destroy!) + if !SiteSetting.log_out_strict && params[:token_id] + UserAuthToken.where(id: params[:token_id], user_id: user.id).each(&:destroy!) + else + UserAuthToken.where(user_id: user.id).each(&:destroy!) + end MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id] diff --git a/app/serializers/concerns/user_auth_tokens_mixin.rb b/app/serializers/concerns/user_auth_tokens_mixin.rb index e4dac0e2a54..39e6e3232a9 100644 --- a/app/serializers/concerns/user_auth_tokens_mixin.rb +++ b/app/serializers/concerns/user_auth_tokens_mixin.rb @@ -1,11 +1,16 @@ +require_dependency 'browser_detection' +require_dependency 'discourse_ip_info' + module UserAuthTokensMixin extend ActiveSupport::Concern included do attributes :id, :client_ip, + :location, + :browser, + :device, :os, - :device_name, :icon, :created_at end @@ -14,55 +19,39 @@ module UserAuthTokensMixin object.client_ip.to_s end - def os - case object.user_agent - when /Android/i - 'Android' - when /iPhone|iPad|iPod/i - 'iOS' - when /Macintosh/i - 'macOS' - when /Linux/i - 'Linux' - when /Windows/i - 'Windows' - else - I18n.t('staff_action_logs.unknown') - end + def location + ipinfo = DiscourseIpInfo.get(client_ip) + + location = [ipinfo[:city], ipinfo[:region], ipinfo[:country]].reject { |x| x.blank? }.join(", ") + return I18n.t('staff_action_logs.unknown') if location.blank? + + location end - def device_name - case object.user_agent - when /Android/i - I18n.t('user_auth_tokens.devices.android') - when /iPad/i - I18n.t('user_auth_tokens.devices.ipad') - when /iPhone/i - I18n.t('user_auth_tokens.devices.iphone') - when /iPod/i - I18n.t('user_auth_tokens.devices.ipod') - when /Mobile/i - I18n.t('user_auth_tokens.devices.mobile') - when /Macintosh/i - I18n.t('user_auth_tokens.devices.mac') - when /Linux/i - I18n.t('user_auth_tokens.devices.linux') - when /Windows/i - I18n.t('user_auth_tokens.devices.windows') - else - I18n.t('user_auth_tokens.devices.unknown') - end + def browser + val = BrowserDetection.browser(object.user_agent) + I18n.t("user_auth_tokens.browser.#{val}") + end + + def device + val = BrowserDetection.device(object.user_agent) + I18n.t("user_auth_tokens.device.#{val}") + end + + def os + val = BrowserDetection.os(object.user_agent) + I18n.t("user_auth_tokens.os.#{val}") end def icon - case os - when 'Android' + case BrowserDetection.os(object.user_agent) + when :android 'android' - when 'macOS', 'iOS' + when :macos, :ios 'apple' - when 'Linux' + when :linux 'linux' - when 'Windows' + when :windows 'windows' else 'question' diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index d389cecafef..fc8c7f46116 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -9,7 +9,8 @@ class PostSerializer < BasicPostSerializer :single_post_link_counts, :draft_sequence, :post_actions, - :all_post_actions + :all_post_actions, + :add_excerpt ] INSTANCE_VARS.each do |v| @@ -70,7 +71,8 @@ class PostSerializer < BasicPostSerializer :action_code, :action_code_who, :last_wiki_edit, - :locked + :locked, + :excerpt def initialize(object, opts) super(object, opts) @@ -97,6 +99,10 @@ class PostSerializer < BasicPostSerializer @add_title end + def include_excerpt? + @add_excerpt + end + def topic_title topic&.title end diff --git a/app/serializers/user_auth_token_serializer.rb b/app/serializers/user_auth_token_serializer.rb index 8a9fa01c992..d7dd0ad489f 100644 --- a/app/serializers/user_auth_token_serializer.rb +++ b/app/serializers/user_auth_token_serializer.rb @@ -2,4 +2,21 @@ class UserAuthTokenSerializer < ApplicationSerializer include UserAuthTokensMixin attributes :seen_at + attributes :is_active + + def include_is_active? + scope && scope.request + end + + def is_active + cookie = scope.request.cookies[Auth::DefaultCurrentUserProvider::TOKEN_COOKIE] + + UserAuthToken.hash_token(cookie) == object.auth_token + end + + def seen_at + return object.created_at unless object.seen_at.present? + + object.seen_at + end end diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index eedadd87bfd..cc39293e2d1 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -197,8 +197,9 @@ class UserSerializer < BasicUserSerializer def user_auth_tokens ActiveModel::ArraySerializer.new( - object.user_auth_tokens.order(:seen_at).reverse_order, - each_serializer: UserAuthTokenSerializer + object.user_auth_tokens, + each_serializer: UserAuthTokenSerializer, + scope: scope ) end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 4617ea7c505..54b9783a9f1 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -865,16 +865,18 @@ en: auth_tokens: title: "Recently Used Devices" - title_logs: "Authentication Logs" - ip_address: "IP Address" - created: "Created" - first_seen: "First Seen" - last_seen: "Last Seen" - operating_system: "Operating System" - location: "Location" - action: "Action" - login: "Log in" - logout: "Log out everywhere" + ip: "IP" + details: "Details" + log_out_all: "Log out all" + active: "active now" + not_you: "Not you?" + show_all: "Show all ({{count}})" + show_few: "Show fewer" + was_this_you: "Was this you?" + was_this_you_description: "If it wasn't you who logged in, we recommend you to change your password and log out of all devices. We also recommend setting up second-factor authentication for a better protection of your account." + browser_and_device: "{{browser}} on {{device}}" + secure_account: "Secure my account" + latest_post: "You last posted..." last_posted: "Last Post" last_emailed: "Last Emailed" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index e5236174f00..21fbb68dd42 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -696,16 +696,30 @@ en: title: "Email login" user_auth_tokens: - devices: - android: 'Android Device' - linux: 'Linux Computer' - windows: 'Windows Computer' - mac: 'Mac' - iphone: 'iPhone' - ipad: 'iPad' - ipod: 'iPod' - mobile: 'Mobile Device' - unknown: 'Unknown device' + browser: + chrome: "Google Chrome" + safari: "Safari" + firefox: "Firefox" + opera: "Opera" + ie: "Internet Explorer" + unknown: "unknown browser" + device: + android: "Android Device" + ipad: "iPad" + iphone: "iPhone" + ipod: "iPod" + mobile: "Mobile Device" + mac: "Mac" + linux: "Linux Computer" + windows: "Windows Computer" + unknown: "unknown device" + os: + android: "Android" + ios: "iOS" + macos: "macOS" + linux: "Linux" + windows: "Microsoft Windows" + unknown: "unknown operating system" change_email: confirmed: "Your email has been updated." diff --git a/config/routes.rb b/config/routes.rb index 631c0dcaaf7..d1d9f36575a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -420,6 +420,7 @@ Discourse::Application.routes.draw do get "#{root_path}/:username/summary" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity/topics.rss" => "list#user_topics_feed", format: :rss, constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity.rss" => "posts#user_posts_feed", format: :rss, constraints: { username: RouteFormat.username } + get "#{root_path}/:username/activity.json" => "posts#user_posts_feed", format: :json, constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/activity/:filter" => "users#show", constraints: { username: RouteFormat.username } get "#{root_path}/:username/badges" => "users#badges", constraints: { username: RouteFormat.username } diff --git a/lib/browser_detection.rb b/lib/browser_detection.rb new file mode 100644 index 00000000000..cf3ea57e6ae --- /dev/null +++ b/lib/browser_detection.rb @@ -0,0 +1,60 @@ +module BrowserDetection + + def self.browser(user_agent) + case user_agent + when /Opera/i, /OPR/i + :opera + when /Firefox/i + :firefox + when /Chrome/i, /CriOS/i + :chrome + when /Safari/i + :safari + when /MSIE/i, /Trident/i + :ie + else + :unknown + end + end + + def self.device(user_agent) + case user_agent + when /Android/i + :android + when /iPad/i + :ipad + when /iPhone/i + :iphone + when /iPod/i + :ipod + when /Mobile/i + :mobile + when /Macintosh/i + :mac + when /Linux/i + :linux + when /Windows/i + :windows + else + :unknown + end + end + + def self.os(user_agent) + case user_agent + when /Android/i + :android + when /iPhone|iPad|iPod/i + :ios + when /Macintosh/i + :macos + when /Linux/i + :linux + when /Windows/i + :windows + else + :unknown + end + end + +end diff --git a/lib/discourse_ip_info.rb b/lib/discourse_ip_info.rb new file mode 100644 index 00000000000..2f627f4c8f4 --- /dev/null +++ b/lib/discourse_ip_info.rb @@ -0,0 +1,46 @@ +require_dependency 'maxminddb' + +class DiscourseIpInfo + include Singleton + + def initialize + begin + @mmdb_filename = File.join(Rails.root, 'vendor', 'data', 'GeoLite2-City.mmdb') + @mmdb = MaxMindDB.new(@mmdb_filename, MaxMindDB::LOW_MEMORY_FILE_READER) + @cache = LruRedux::ThreadSafeCache.new(1000) + rescue Errno::ENOENT => e + Rails.logger.warn("MaxMindDB could not be found: #{e}") + rescue + Rails.logger.warn("MaxMindDB could not be loaded.") + end + end + + def lookup(ip) + return {} unless @mmdb + + begin + result = @mmdb.lookup(ip) + rescue + Rails.logger.error("IP #{ip} could not be looked up in MaxMindDB.") + end + + return {} if !result || !result.found? + + { + country: result.country.name, + country_code: result.country.iso_code, + region: result.subdivisions.most_specific.name, + city: result.city.name, + } + end + + def get(ip) + return {} unless @mmdb + + @cache[ip] ||= lookup(ip) + end + + def self.get(ip) + instance.get(ip) + end +end diff --git a/lib/guardian.rb b/lib/guardian.rb index e8bb517f80b..2804b23c84e 100644 --- a/lib/guardian.rb +++ b/lib/guardian.rb @@ -33,9 +33,11 @@ class Guardian end attr_accessor :can_see_emails + attr_reader :request - def initialize(user = nil) + def initialize(user = nil, request = nil) @user = user.presence || AnonymousUser.new + @request = request end def user diff --git a/lib/tasks/maxminddb.rake b/lib/tasks/maxminddb.rake new file mode 100644 index 00000000000..2af862a72dd --- /dev/null +++ b/lib/tasks/maxminddb.rake @@ -0,0 +1,22 @@ +require 'rubygems/package' +require 'zlib' + +desc "downloads MaxMind's GeoLite2-City database" +task "maxminddb:get" => :environment do + uri = URI("http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.tar.gz") + tar_gz_archive = Net::HTTP.get(uri) + + extractor = Gem::Package::TarReader.new(Zlib::GzipReader.new(StringIO.new(tar_gz_archive))) + extractor.rewind + + extractor.each do |entry| + next unless entry.full_name.ends_with?(".mmdb") + + filename = File.join(Rails.root, 'vendor', 'data', 'GeoLite2-City.mmdb') + File.open(filename, "wb") do |f| + f.write(entry.read) + end + end + + extractor.close +end diff --git a/spec/lib/browser_detection_spec.rb b/spec/lib/browser_detection_spec.rb new file mode 100644 index 00000000000..6a4722ca741 --- /dev/null +++ b/spec/lib/browser_detection_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' +require 'browser_detection' + +describe BrowserDetection do + + it "detects browser, device and operating system" do + [ + ["Mozilla/4.0 (compatible; MSIE 9.0; Windows NT 6.1)", :ie, :windows, :windows], + ["Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)", :ie, :windows, :windows], + ["Mozilla/5.0 (iPad; CPU OS 9_3_2 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13F69 Safari/601.1", :safari, :ipad, :ios], + ["Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1", :safari, :iphone, :ios], + ["Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_2 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/51.0.2704.104 Mobile/13F69 Safari/601.1.46", :chrome, :iphone, :ios], + ["Mozilla/5.0 (Linux; Android 4.1.1; Nexus 7 Build/JRO03D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Safari/535.19", :chrome, :android, :android], + ["Mozilla/5.0 (Linux; Android 4.4.2; XMP-6250 Build/HAWK) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Safari/537.36 ADAPI/2.0 (UUID:9e7df0ed-2a5c-4a19-bec7-2cc54800f99d) RK3188-ADAPI/1.2.84.533 (MODEL:XMP-6250)", :chrome, :android, :android], + ["Mozilla/5.0 (Linux; Android 5.1; Nexus 7 Build/LMY47O) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.105 Safari/537.36", :chrome, :android, :android], + ["Mozilla/5.0 (Linux; Android 6.0.1; vivo 1603 Build/MMB29M) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.83 Mobile Safari/537.36", :chrome, :android, :android], + ["Mozilla/5.0 (Linux; Android; 4.1.2; GT-I9100 Build/000000) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1234.12 Mobile Safari/537.22 OPR/14.0.123.123", :opera, :android, :android], + ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :mac, :macos], + ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :mac, :macos], + ["Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.90 Safari/537.36", :chrome, :mac, :macos], + ["Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36", :chrome, :windows, :windows], + ["Mozilla/5.0 (Windows NT 5.1; rv:7.0.1) Gecko/20100101 Firefox/7.0.1", :firefox, :windows, :windows], + ["Mozilla/5.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko", :ie, :windows, :windows], + ["Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36", :chrome, :windows, :windows], + ["Mozilla/5.0 (Windows NT 6.1; WOW64; rv:40.0) Gecko/20100101 Firefox/40.1", :firefox, :windows, :windows], + ["Mozilla/5.0 (Windows NT 6.1; WOW64; rv:54.0) Gecko/20100101 Firefox/54.0", :firefox, :windows, :windows], + ["Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.157 Safari/537.36", :chrome, :linux, :linux], + ["Mozilla/5.0 (X11; Linux x86_64; rv:10.0) Gecko/20150101 Firefox/47.0 (Chrome)", :firefox, :linux, :linux], + ["Opera/9.80 (X11; Linux zvav; U; en) Presto/2.12.423 Version/12.16", :opera, :linux, :linux], + ].each do |user_agent, browser, device, os| + expect(BrowserDetection.browser(user_agent)).to eq(browser) + expect(BrowserDetection.device(user_agent)).to eq(device) + expect(BrowserDetection.os(user_agent)).to eq(os) + end + end + +end diff --git a/spec/requests/posts_controller_spec.rb b/spec/requests/posts_controller_spec.rb index 2e0264726f4..57ffcea60bc 100644 --- a/spec/requests/posts_controller_spec.rb +++ b/spec/requests/posts_controller_spec.rb @@ -1460,6 +1460,20 @@ describe PostsController do expect(body).to_not include(private_post.url) expect(body).to include(public_post.url) end + + it 'returns public posts as JSON' do + public_post + private_post + + get "/u/#{user.username}/activity.json" + + expect(response.status).to eq(200) + + body = response.body + + expect(body).to_not include(private_post.topic.slug) + expect(body).to include(public_post.topic.slug) + end end describe '#latest' do diff --git a/spec/requests/users_controller_spec.rb b/spec/requests/users_controller_spec.rb index 7697f083453..c7e76f97acb 100644 --- a/spec/requests/users_controller_spec.rb +++ b/spec/requests/users_controller_spec.rb @@ -3242,10 +3242,36 @@ describe UsersController do context 'while logged in' do before do sign_in(user) + sign_in(user) end it 'logs user out' do + SiteSetting.log_out_strict = false + expect(user.user_auth_tokens.count).to eq(2) + + ids = user.user_auth_tokens.map { |token| token.id } + post "/u/#{user.username}/preferences/revoke-auth-token.json", params: { token_id: ids[0] } + + expect(response.status).to eq(200) + + user.user_auth_tokens.reload expect(user.user_auth_tokens.count).to eq(1) + expect(user.user_auth_tokens.first.id).to eq(ids[1]) + end + + it 'logs user out from everywhere if log_out_strict is enabled' do + SiteSetting.log_out_strict = true + expect(user.user_auth_tokens.count).to eq(2) + + ids = user.user_auth_tokens.map { |token| token.id } + post "/u/#{user.username}/preferences/revoke-auth-token.json", params: { token_id: ids[0] } + + expect(response.status).to eq(200) + expect(user.user_auth_tokens.count).to eq(0) + end + + it 'logs user out from everywhere if token_id is not present' do + expect(user.user_auth_tokens.count).to eq(2) post "/u/#{user.username}/preferences/revoke-auth-token.json" diff --git a/spec/support/helpers.rb b/spec/support/helpers.rb index a137b1e12d3..8877d23a9f0 100644 --- a/spec/support/helpers.rb +++ b/spec/support/helpers.rb @@ -58,7 +58,7 @@ module Helpers def stub_guardian(user) guardian = Guardian.new(user) yield(guardian) if block_given? - Guardian.stubs(new: guardian).with(user) + Guardian.stubs(new: guardian).with(user, anything) end def wait_for(on_fail: nil, &blk) diff --git a/test/javascripts/acceptance/preferences-test.js.es6 b/test/javascripts/acceptance/preferences-test.js.es6 index 66d3269b211..71961b489fc 100644 --- a/test/javascripts/acceptance/preferences-test.js.es6 +++ b/test/javascripts/acceptance/preferences-test.js.es6 @@ -39,6 +39,10 @@ acceptance("User Preferences", { gravatar_avatar_template: "something" }); }); + + server.get("/u/eviltrout/activity.json", () => { + return helper.response({}); + }); } }); @@ -248,3 +252,38 @@ QUnit.test("visit my preferences", async assert => { ); assert.ok(exists(".user-preferences"), "it shows the preferences"); }); + +QUnit.test("recently connected devices", async assert => { + await visit("/u/eviltrout/preferences"); + + assert.equal( + find(".pref-auth-tokens > a:first") + .text() + .trim(), + I18n.t("user.auth_tokens.show_all", { count: 3 }), + "it should display two tokens" + ); + assert.ok( + find(".pref-auth-tokens .auth-token").length === 2, + "it should display two tokens" + ); + + await click(".pref-auth-tokens > a:first"); + + assert.ok( + find(".pref-auth-tokens .auth-token").length === 3, + "it should display three tokens" + ); + + await click(".auth-token-dropdown:first button"); + await click("li[data-value='notYou']"); + + assert.ok(find(".d-modal:visible").length === 1, "modal should appear"); + + await click(".modal-footer .btn-primary"); + + assert.ok( + find(".pref-password.highlighted").length === 1, + "it should highlight password preferences" + ); +}); diff --git a/test/javascripts/fixtures/user_fixtures.js.es6 b/test/javascripts/fixtures/user_fixtures.js.es6 index e397ce918c7..b0fd0f7f97b 100644 --- a/test/javascripts/fixtures/user_fixtures.js.es6 +++ b/test/javascripts/fixtures/user_fixtures.js.es6 @@ -236,7 +236,45 @@ export default { badge_grouping_id: 8, system: false, badge_type_id: 3 - } + }, + user_auth_tokens: [ + { + id: 2, + client_ip: "188.192.99.49", + location: "Augsburg, Bavaria, Germany", + browser: "Google Chrome", + device: "Linux Computer", + os: "Linux", + icon: "linux", + created_at: "2018-09-08T21:22:56.225Z", + seen_at: "2018-09-08T21:22:56.512Z", + is_active: false + }, + { + id: 3, + client_ip: "188.120.223.89", + location: "České Budějovice, České Budějovice District, Czechia", + browser: "Google Chrome", + device: "Linux Computer", + os: "Linux", + icon: "linux", + created_at: "2018-09-08T21:33:41.616Z", + seen_at: "2018-09-08T21:33:42.209Z", + is_active: true + }, + { + id: 6, + client_ip: "188.233.223.89", + location: "Tula, Tul'skaya Oblast, Russia", + browser: "Internet Explorer", + device: "Windows Computer", + os: "Windows", + icon: "windows", + created_at: "2018-09-07T21:44:41.616Z", + seen_at: "2018-09-08T21:44:42.209Z", + is_active: false + } + ] } }, "/user_actions.json": {