mirror of
https://github.com/discourse/discourse.git
synced 2024-11-22 08:57:10 -06:00
FEATURE: Let users see their user auth tokens. (#6313)
This commit is contained in:
parent
b3aab1770f
commit
931cffcebe
@ -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();
|
||||
}
|
||||
|
@ -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">— {{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/>
|
||||
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -1,4 +1,5 @@
|
||||
class UserAuthTokenLog < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
|
88
app/serializers/user_auth_token_serializer.rb
Normal file
88
app/serializers/user_auth_token_serializer.rb
Normal 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
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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}"
|
||||
|
@ -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 }
|
||||
|
@ -333,7 +333,7 @@ login:
|
||||
verbose_sso_logging: false
|
||||
verbose_auth_token_logging:
|
||||
hidden: true
|
||||
default: false
|
||||
default: true
|
||||
sso_url:
|
||||
default: ''
|
||||
regex: '^https?:\/\/.+[^\/]$'
|
||||
|
@ -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 = ""
|
||||
|
Loading…
Reference in New Issue
Block a user