FEATURE: Let users see their user auth tokens. (#6313)

This commit is contained in:
Bianca Nenciu 2018-08-31 10:18:06 +02:00 committed by Régis Hanol
parent b3aab1770f
commit 931cffcebe
14 changed files with 290 additions and 4 deletions

View File

@ -6,6 +6,8 @@ import { setting } from "discourse/lib/computed";
import { popupAjaxError } from "discourse/lib/ajax-error";
import showModal from "discourse/lib/show-modal";
import { findAll } from "discourse/models/login-method";
import { ajax } from "discourse/lib/ajax";
import { userPath } from "discourse/lib/url";
export default Ember.Controller.extend(
CanCheckEmails,
@ -194,6 +196,17 @@ export default Ember.Controller.extend(
});
},
toggleToken(token) {
Ember.set(token, 'visible', !token.visible);
},
revokeAuthToken() {
ajax(
userPath(`${this.get('model.username_lower')}/preferences/revoke-auth-token`),
{ type: "POST" }
);
},
connectAccount(method) {
method.doLogin();
}

View File

@ -161,6 +161,74 @@
</div>
{{/if}}
{{#if canCheckEmails}}
<div class="control-group pref-auth-tokens">
<label class="control-label">{{i18n 'user.auth_tokens.title'}}</label>
<a {{action "revokeAuthToken"}}>{{d-icon "sign-out"}} {{i18n 'user.auth_tokens.logout'}}</a>
{{#each model.user_auth_tokens as |token|}}
<div class="perf-auth-token">
<div class="row auth-token-summary">
<div class="auth-token-label">
{{d-icon token.icon}} {{token.device_name}}
{{#if token.visible}}
<a {{action "toggleToken" token}}>{{d-icon "angle-double-up"}}</a>
{{else}}
<a {{action "toggleToken" token}}>{{d-icon "angle-double-down"}}</a>
{{/if}}
</div>
<div class="auth-token-value">
{{token.client_ip}} <span class="muted">&mdash; {{format-date token.seen_at}}</span>
</div>
</div>
{{#if token.visible}}
<div class="auth-token-details">
<div class="row">
<div class="auth-token-label">{{i18n 'user.auth_tokens.ip_address'}}</div>
<div class="auth-token-value">{{token.client_ip}}</div>
</div>
<div class="row">
<div class="auth-token-label">{{i18n 'user.auth_tokens.first_seen'}}</div>
<div class="auth-token-value">{{format-date token.created_at}}</div>
</div>
<div class="row">
<div class="auth-token-label">{{i18n 'user.auth_tokens.last_seen'}}</div>
<div class="auth-token-value">{{format-date token.seen_at}}</div>
</div>
<div class="row">
<div class="auth-token-label">{{i18n 'user.auth_tokens.operating_system'}}</div>
<div class="auth-token-value">{{token.os}}</div>
</div>
</div>
{{/if}}
</div>
{{/each}}
</div>
{{/if}}
{{#if canCheckEmails}}
{{#if model.staff}}
<div class="control-group pref-auth-tokens">
<label class="control-label">{{i18n 'user.auth_tokens.title_logs'}}</label>
{{#if model.user_auth_token_logs}}
<table class="table">
<tr>
<th>{{i18n 'user.auth_tokens.ip_address'}}</th>
<th>{{i18n 'user.auth_tokens.created'}}</th>
<th>{{i18n 'user.auth_tokens.operating_system'}}</th>
</tr>
{{#each model.user_auth_token_logs as |token|}}
<tr>
<td>{{token.client_ip}}</td>
<td>{{format-date token.created_at}}</td>
<td>{{d-icon token.icon}} {{token.os}}</td>
</tr>
{{/each}}
</table>
{{/if}}
</div>
{{/if}}
{{/if}}
{{plugin-outlet name="user-preferences-account" args=(hash model=model save=(action "save"))}}
<br/>

View File

@ -475,6 +475,16 @@ select {
.control-group {
@include clearfix;
.table {
width: 100%;
th,
td {
padding: 10px;
text-align: center;
}
}
}
.control-label {
@ -544,3 +554,47 @@ select {
.inline {
display: inline;
}
.pref-auth-tokens {
.control-label {
display: inline-block;
}
.row {
margin: 5px 0px;
}
.muted {
color: #888;
}
.perf-auth-token {
background: #f9f9f9;
padding: 5px;
margin-bottom: 10px;
}
.auth-token-summary {
padding: 0px 10px;
.auth-token-label, .auth-token-value {
font-size: 1.2em;
margin-top: 5px;
}
}
.auth-token-details {
background: #fff;
padding: 5px 10px;
margin: 10px 5px 5px 5px;
.auth-token-label {
color: #888;
}
}
.auth-token-label, .auth-token-value {
float: left;
width: 50%;
}
}

View File

@ -13,7 +13,8 @@ class UsersController < ApplicationController
:username, :update, :user_preferences_redirect, :upload_user_image,
:pick_avatar, :destroy_user_image, :destroy, :check_emails,
:topic_tracking_state, :preferences, :create_second_factor,
:update_second_factor, :create_second_factor_backup, :select_avatar
:update_second_factor, :create_second_factor_backup, :select_avatar,
:revoke_auth_token
]
skip_before_action :check_xhr, only: [
@ -1097,6 +1098,19 @@ class UsersController < ApplicationController
end
end
def revoke_auth_token
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
UserAuthToken.where(user_id: user.id).destroy_all
MessageBus.publish "/file-change", ["refresh"], user_ids: [user.id]
render json: {
success: true
}
end
private
def honeypot_value

View File

@ -47,6 +47,7 @@ class User < ActiveRecord::Base
has_many :email_change_requests, dependent: :destroy
has_many :directory_items, dependent: :delete_all
has_many :user_auth_tokens, dependent: :destroy
has_many :user_auth_token_logs, dependent: :destroy
has_many :group_users, dependent: :destroy
has_many :groups, through: :group_users

View File

@ -11,8 +11,19 @@ class UserAuthToken < ActiveRecord::Base
# used when token did not arrive at client
URGENT_ROTATE_TIME = 1.minute
USER_ACTIONS = ['generate']
attr_accessor :unhashed_auth_token
before_destroy do
UserAuthToken.log(action: 'destroy',
user_auth_token_id: self.id,
user_id: self.user_id,
user_agent: self.user_agent,
client_ip: self.client_ip,
auth_token: self.auth_token)
end
def self.log(info)
if SiteSetting.verbose_auth_token_logging
UserAuthTokenLog.create!(info)

View File

@ -1,4 +1,5 @@
class UserAuthTokenLog < ActiveRecord::Base
belongs_to :user
end
# == Schema Information

View File

@ -0,0 +1,88 @@
class UserAuthTokenSerializer < ApplicationSerializer
attributes :id,
:action,
:client_ip,
:created_at,
:seen_at,
:os,
:device_name,
:icon
def action
case object.action
when 'generate'
I18n.t('log_in')
when 'destroy'
I18n.t('unsubscribe.log_out')
else
I18n.t('staff_action_logs.unknown')
end
end
def include_action?
object.has_attribute?(:action)
end
def client_ip
object.client_ip.to_s
end
def include_seen_at?
object.has_attribute?(:seen_at)
end
def os
case object.user_agent
when /Android/i
'Android'
when /Linux/i
'Linux'
when /Windows/i
'Windows'
when /macOS/i
'macOS'
when /iPhone|iPad|iPod/i
'iOS'
else
I18n.t('staff_action_logs.unknown')
end
end
def device_name
case object.user_agent
when /Android/i
I18n.t('user_auth_tokens.devices.android')
when /Linux/i
I18n.t('user_auth_tokens.devices.linux')
when /Windows/i
I18n.t('user_auth_tokens.devices.windows')
when /macOS/i
I18n.t('user_auth_tokens.devices.mac')
when /iPhone/i
I18n.t('user_auth_tokens.devices.iphone')
when /iPad/i
I18n.t('user_auth_tokens.devices.ipad')
when /iPod/i
I18n.t('user_auth_tokens.devices.ipod')
when /Mobile/i
I18n.t('user_auth_tokens.devices.mobile')
else
I18n.t('user_auth_tokens.devices.unknown')
end
end
def icon
case os
when 'Linux'
'linux'
when 'Windows'
'windows'
when 'macOS', 'iOS'
'apple'
when 'Android'
'android'
else
'question'
end
end
end

View File

@ -112,7 +112,9 @@ class UserSerializer < BasicUserSerializer
:muted_usernames,
:mailing_list_posts_per_day,
:can_change_bio,
:user_api_keys
:user_api_keys,
:user_auth_tokens,
:user_auth_token_logs
untrusted_attributes :bio_raw,
:bio_cooked,
@ -193,6 +195,14 @@ class UserSerializer < BasicUserSerializer
keys.length > 0 ? keys : nil
end
def user_auth_tokens
ActiveModel::ArraySerializer.new(object.user_auth_tokens.order(:seen_at).reverse_order, each_serializer: UserAuthTokenSerializer)
end
def user_auth_token_logs
ActiveModel::ArraySerializer.new(object.user_auth_token_logs.where(action: UserAuthToken::USER_ACTIONS).order(:created_at).reverse_order, each_serializer: UserAuthTokenSerializer)
end
def bio_raw
object.user_profile.bio_raw
end

View File

@ -863,6 +863,19 @@ en:
password_confirmation:
title: "Password Again"
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"
last_posted: "Last Post"
last_emailed: "Last Emailed"
last_seen: "Seen"

View File

@ -698,6 +698,18 @@ en:
invalid_token: "Sorry, that email login link is too old. Select the Log In button and use 'I forgot my password' to get a new link."
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'
change_email:
confirmed: "Your email has been updated."
please_continue: "Continue to %{site_name}"

View File

@ -413,6 +413,7 @@ Discourse::Application.routes.draw do
put "#{root_path}/:username/preferences/avatar/pick" => "users#pick_avatar", constraints: { username: RouteFormat.username }
put "#{root_path}/:username/preferences/avatar/select" => "users#select_avatar", constraints: { username: RouteFormat.username }
post "#{root_path}/:username/preferences/revoke-account" => "users#revoke_account", constraints: { username: RouteFormat.username }
post "#{root_path}/:username/preferences/revoke-auth-token" => "users#revoke_auth_token", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/staff-info" => "users#staff_info", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/summary" => "users#summary", constraints: { username: RouteFormat.username }
get "#{root_path}/:username/invited" => "users#invited", constraints: { username: RouteFormat.username }

View File

@ -333,7 +333,7 @@ login:
verbose_sso_logging: false
verbose_auth_token_logging:
hidden: true
default: false
default: true
sso_url:
default: ''
regex: '^https?:\/\/.+[^\/]$'

View File

@ -21,7 +21,7 @@ RSpec.describe WebHookUserSerializer do
it 'should only include the required keys' do
count = serializer.as_json.keys.count
difference = count - 43
difference = count - 45
expect(difference).to eq(0), lambda {
message = ""