Audit log for user authentication
This commit is contained in:
parent
8ba83cbab8
commit
411829fdb5
21 changed files with 144 additions and 33 deletions
|
@ -28,7 +28,8 @@ v 7.13.0 (unreleased)
|
||||||
- Users with guest access level can not set assignee, labels or milestones for issue and merge request
|
- Users with guest access level can not set assignee, labels or milestones for issue and merge request
|
||||||
- Reporter role can manage issue tracker now: edit any issue, set assignee or milestone and manage labels
|
- Reporter role can manage issue tracker now: edit any issue, set assignee or milestone and manage labels
|
||||||
- Better performance for pages with events list, issues list and commits list
|
- Better performance for pages with events list, issues list and commits list
|
||||||
- Faster automerge check and merge itself when source and target branches are in same repository
|
- Faster automerge check and merge itself when source and target branches are in same repository
|
||||||
|
- Audit log for user authentication
|
||||||
|
|
||||||
v 7.12.1
|
v 7.12.1
|
||||||
- Fix error when deleting a user who has projects (Stan Hu)
|
- Fix error when deleting a user who has projects (Stan Hu)
|
||||||
|
|
|
@ -28,6 +28,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
|
|
||||||
# Do additional LDAP checks for the user filter and EE features
|
# Do additional LDAP checks for the user filter and EE features
|
||||||
if @user.allowed?
|
if @user.allowed?
|
||||||
|
log_audit_event(gl_user, with: :ldap)
|
||||||
sign_in_and_redirect(gl_user)
|
sign_in_and_redirect(gl_user)
|
||||||
else
|
else
|
||||||
flash[:alert] = "Access denied for your LDAP account."
|
flash[:alert] = "Access denied for your LDAP account."
|
||||||
|
@ -47,6 +48,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
if current_user
|
if current_user
|
||||||
# Add new authentication method
|
# Add new authentication method
|
||||||
current_user.identities.find_or_create_by(extern_uid: oauth['uid'], provider: oauth['provider'])
|
current_user.identities.find_or_create_by(extern_uid: oauth['uid'], provider: oauth['provider'])
|
||||||
|
log_audit_event(current_user, with: oauth['provider'])
|
||||||
redirect_to profile_account_path, notice: 'Authentication method updated'
|
redirect_to profile_account_path, notice: 'Authentication method updated'
|
||||||
else
|
else
|
||||||
@user = Gitlab::OAuth::User.new(oauth)
|
@user = Gitlab::OAuth::User.new(oauth)
|
||||||
|
@ -54,6 +56,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
|
|
||||||
# Only allow properly saved users to login.
|
# Only allow properly saved users to login.
|
||||||
if @user.persisted? && @user.valid?
|
if @user.persisted? && @user.valid?
|
||||||
|
log_audit_event(@user.gl_user, with: oauth['provider'])
|
||||||
sign_in_and_redirect(@user.gl_user)
|
sign_in_and_redirect(@user.gl_user)
|
||||||
else
|
else
|
||||||
error_message =
|
error_message =
|
||||||
|
@ -83,4 +86,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||||
def oauth
|
def oauth
|
||||||
@oauth ||= request.env['omniauth.auth']
|
@oauth ||= request.env['omniauth.auth']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def log_audit_event(user, options = {})
|
||||||
|
AuditEventService.new(user, user, options).
|
||||||
|
for_authentication.security_event
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -37,8 +37,11 @@ class ProfilesController < Profiles::ApplicationController
|
||||||
redirect_to profile_account_path
|
redirect_to profile_account_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def history
|
def audit_log
|
||||||
@events = current_user.recent_events.page(params[:page]).per(PER_PAGE)
|
@events = AuditEvent.where(entity_type: "User", entity_id: current_user.id).
|
||||||
|
order("created_at DESC").
|
||||||
|
page(params[:page]).
|
||||||
|
per(PER_PAGE)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_username
|
def update_username
|
||||||
|
|
|
@ -37,6 +37,8 @@ class SessionsController < Devise::SessionsController
|
||||||
resource.update_attributes(reset_password_token: nil,
|
resource.update_attributes(reset_password_token: nil,
|
||||||
reset_password_sent_at: nil)
|
reset_password_sent_at: nil)
|
||||||
end
|
end
|
||||||
|
authenticated_with = user_params[:otp_attempt] ? "two-factor" : "standard"
|
||||||
|
log_audit_event(current_user, with: authenticated_with)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -95,4 +97,9 @@ class SessionsController < Devise::SessionsController
|
||||||
user.valid_otp?(user_params[:otp_attempt]) ||
|
user.valid_otp?(user_params[:otp_attempt]) ||
|
||||||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def log_audit_event(user, options = {})
|
||||||
|
AuditEventService.new(user, user, options).
|
||||||
|
for_authentication.security_event
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
19
app/models/audit_event.rb
Normal file
19
app/models/audit_event.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
class AuditEvent < ActiveRecord::Base
|
||||||
|
serialize :details, Hash
|
||||||
|
|
||||||
|
belongs_to :user, foreign_key: :author_id
|
||||||
|
|
||||||
|
validates :author_id, presence: true
|
||||||
|
validates :entity_id, presence: true
|
||||||
|
validates :entity_type, presence: true
|
||||||
|
|
||||||
|
after_initialize :initialize_details
|
||||||
|
|
||||||
|
def initialize_details
|
||||||
|
self.details = {} if details.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def author_name
|
||||||
|
self.user.name
|
||||||
|
end
|
||||||
|
end
|
2
app/models/security_event.rb
Normal file
2
app/models/security_event.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class SecurityEvent < AuditEvent
|
||||||
|
end
|
25
app/services/audit_event_service.rb
Normal file
25
app/services/audit_event_service.rb
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
class AuditEventService
|
||||||
|
def initialize(author, entity, details = {})
|
||||||
|
@author, @entity, @details = author, entity, details
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_authentication
|
||||||
|
@details = {
|
||||||
|
with: @details[:with],
|
||||||
|
target_id: @author.id,
|
||||||
|
target_type: "User",
|
||||||
|
target_details: @author.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def security_event
|
||||||
|
SecurityEvent.create(
|
||||||
|
author_id: @author.id,
|
||||||
|
entity_id: @entity.id,
|
||||||
|
entity_type: @entity.class.name,
|
||||||
|
details: @details
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -44,8 +44,8 @@
|
||||||
= icon('image fw')
|
= icon('image fw')
|
||||||
%span
|
%span
|
||||||
Preferences
|
Preferences
|
||||||
= nav_link(path: 'profiles#history') do
|
= nav_link(path: 'profiles#audit_log') do
|
||||||
= link_to history_profile_path, title: 'History', data: {placement: 'right'} do
|
= link_to audit_log_profile_path, title: 'Audit Log', data: {placement: 'right'} do
|
||||||
= icon('history fw')
|
= icon('history fw')
|
||||||
%span
|
%span
|
||||||
History
|
Audit Log
|
||||||
|
|
16
app/views/profiles/_event_table.html.haml
Normal file
16
app/views/profiles/_event_table.html.haml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
%table.table#audits
|
||||||
|
%thead
|
||||||
|
%tr
|
||||||
|
%th Action
|
||||||
|
%th When
|
||||||
|
|
||||||
|
%tbody
|
||||||
|
- events.each do |event|
|
||||||
|
%tr
|
||||||
|
%td
|
||||||
|
%span
|
||||||
|
Signed in with
|
||||||
|
%b= event.details[:with]
|
||||||
|
authentication
|
||||||
|
%td #{time_ago_in_words event.created_at} ago
|
||||||
|
= paginate events, theme: "gitlab"
|
5
app/views/profiles/audit_log.html.haml
Normal file
5
app/views/profiles/audit_log.html.haml
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
- page_title "Audit Log"
|
||||||
|
%h3.page-title Audit Log
|
||||||
|
%p.light History of authentications
|
||||||
|
|
||||||
|
= render 'event_table', events: @events
|
|
@ -1,11 +0,0 @@
|
||||||
- page_title "History"
|
|
||||||
%h3.page-title
|
|
||||||
Your Account History
|
|
||||||
%p.light
|
|
||||||
All events created by your account are listed below.
|
|
||||||
%hr
|
|
||||||
.profile_history
|
|
||||||
= render @events
|
|
||||||
%hr
|
|
||||||
= paginate @events, theme: "gitlab"
|
|
||||||
|
|
|
@ -207,7 +207,7 @@ Gitlab::Application.routes.draw do
|
||||||
#
|
#
|
||||||
resource :profile, only: [:show, :update] do
|
resource :profile, only: [:show, :update] do
|
||||||
member do
|
member do
|
||||||
get :history
|
get :audit_log
|
||||||
get :applications
|
get :applications
|
||||||
|
|
||||||
put :reset_private_token
|
put :reset_private_token
|
||||||
|
|
22
db/migrate/20141118150935_add_audit_event.rb
Normal file
22
db/migrate/20141118150935_add_audit_event.rb
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
class AddAuditEvent < ActiveRecord::Migration
|
||||||
|
def change
|
||||||
|
create_table :audit_events do |t|
|
||||||
|
t.integer :author_id, null: false
|
||||||
|
t.string :type, null: false
|
||||||
|
|
||||||
|
# "Namespace" where the change occurs
|
||||||
|
# eg. On a project, group or user
|
||||||
|
t.integer :entity_id, null: false
|
||||||
|
t.string :entity_type, null: false
|
||||||
|
|
||||||
|
# Details for the event
|
||||||
|
t.text :details
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :audit_events, :author_id
|
||||||
|
add_index :audit_events, :type
|
||||||
|
add_index :audit_events, [:entity_id, :entity_type]
|
||||||
|
end
|
||||||
|
end
|
18
db/schema.rb
18
db/schema.rb
|
@ -28,16 +28,30 @@ ActiveRecord::Schema.define(version: 20150620233230) do
|
||||||
t.integer "default_branch_protection", default: 2
|
t.integer "default_branch_protection", default: 2
|
||||||
t.boolean "twitter_sharing_enabled", default: true
|
t.boolean "twitter_sharing_enabled", default: true
|
||||||
t.text "restricted_visibility_levels"
|
t.text "restricted_visibility_levels"
|
||||||
t.boolean "version_check_enabled", default: true
|
|
||||||
t.integer "max_attachment_size", default: 10, null: false
|
t.integer "max_attachment_size", default: 10, null: false
|
||||||
t.integer "default_project_visibility"
|
t.integer "default_project_visibility"
|
||||||
t.integer "default_snippet_visibility"
|
t.integer "default_snippet_visibility"
|
||||||
t.text "restricted_signup_domains"
|
t.text "restricted_signup_domains"
|
||||||
|
t.boolean "version_check_enabled", default: true
|
||||||
t.boolean "user_oauth_applications", default: true
|
t.boolean "user_oauth_applications", default: true
|
||||||
t.string "after_sign_out_path"
|
t.string "after_sign_out_path"
|
||||||
t.integer "session_expire_delay", default: 10080, null: false
|
t.integer "session_expire_delay", default: 10080, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "audit_events", force: true do |t|
|
||||||
|
t.integer "author_id", null: false
|
||||||
|
t.string "type", null: false
|
||||||
|
t.integer "entity_id", null: false
|
||||||
|
t.string "entity_type", null: false
|
||||||
|
t.text "details"
|
||||||
|
t.datetime "created_at"
|
||||||
|
t.datetime "updated_at"
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index "audit_events", ["author_id"], name: "index_audit_events_on_author_id", using: :btree
|
||||||
|
add_index "audit_events", ["entity_id", "entity_type"], name: "index_audit_events_on_entity_id_and_entity_type", using: :btree
|
||||||
|
add_index "audit_events", ["type"], name: "index_audit_events_on_type", using: :btree
|
||||||
|
|
||||||
create_table "broadcast_messages", force: true do |t|
|
create_table "broadcast_messages", force: true do |t|
|
||||||
t.text "message", null: false
|
t.text "message", null: false
|
||||||
t.datetime "starts_at"
|
t.datetime "starts_at"
|
||||||
|
@ -496,12 +510,12 @@ ActiveRecord::Schema.define(version: 20150620233230) do
|
||||||
t.string "bitbucket_access_token"
|
t.string "bitbucket_access_token"
|
||||||
t.string "bitbucket_access_token_secret"
|
t.string "bitbucket_access_token_secret"
|
||||||
t.string "location"
|
t.string "location"
|
||||||
|
t.string "public_email", default: "", null: false
|
||||||
t.string "encrypted_otp_secret"
|
t.string "encrypted_otp_secret"
|
||||||
t.string "encrypted_otp_secret_iv"
|
t.string "encrypted_otp_secret_iv"
|
||||||
t.string "encrypted_otp_secret_salt"
|
t.string "encrypted_otp_secret_salt"
|
||||||
t.boolean "otp_required_for_login", default: false, null: false
|
t.boolean "otp_required_for_login", default: false, null: false
|
||||||
t.text "otp_backup_codes"
|
t.text "otp_backup_codes"
|
||||||
t.string "public_email", default: "", null: false
|
|
||||||
t.integer "dashboard", default: 0
|
t.integer "dashboard", default: 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ Feature: Profile Active Tab
|
||||||
Then the active main tab should be Preferences
|
Then the active main tab should be Preferences
|
||||||
And no other main tabs should be active
|
And no other main tabs should be active
|
||||||
|
|
||||||
Scenario: On Profile History
|
Scenario: On Profile Audit Log
|
||||||
Given I visit profile history page
|
Given I visit Audit Log page
|
||||||
Then the active main tab should be History
|
Then the active main tab should be Audit Log
|
||||||
And no other main tabs should be active
|
And no other main tabs should be active
|
||||||
|
|
|
@ -63,7 +63,7 @@ Feature: Profile
|
||||||
|
|
||||||
Scenario: I visit history tab
|
Scenario: I visit history tab
|
||||||
Given I have activity
|
Given I have activity
|
||||||
When I visit profile history page
|
When I visit Audit Log page
|
||||||
Then I should see my activity
|
Then I should see my activity
|
||||||
|
|
||||||
Scenario: I visit my user page
|
Scenario: I visit my user page
|
||||||
|
|
|
@ -19,7 +19,7 @@ class Spinach::Features::ProfileActiveTab < Spinach::FeatureSteps
|
||||||
ensure_active_main_tab('Preferences')
|
ensure_active_main_tab('Preferences')
|
||||||
end
|
end
|
||||||
|
|
||||||
step 'the active main tab should be History' do
|
step 'the active main tab should be Audit Log' do
|
||||||
ensure_active_main_tab('History')
|
ensure_active_main_tab('Audit Log')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -115,7 +115,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps
|
||||||
end
|
end
|
||||||
|
|
||||||
step 'I should see my activity' do
|
step 'I should see my activity' do
|
||||||
expect(page).to have_content "#{current_user.name} closed issue"
|
expect(page).to have_content "Signed in with standard authentication"
|
||||||
end
|
end
|
||||||
|
|
||||||
step 'my password is expired' do
|
step 'my password is expired' do
|
||||||
|
|
|
@ -127,8 +127,8 @@ module SharedPaths
|
||||||
visit profile_preferences_path
|
visit profile_preferences_path
|
||||||
end
|
end
|
||||||
|
|
||||||
step 'I visit profile history page' do
|
step 'I visit Audit Log page' do
|
||||||
visit history_profile_path
|
visit audit_log_profile_path
|
||||||
end
|
end
|
||||||
|
|
||||||
# ----------------------------------------
|
# ----------------------------------------
|
||||||
|
|
|
@ -45,8 +45,8 @@ describe "Profile access", feature: true do
|
||||||
it { is_expected.to be_denied_for :visitor }
|
it { is_expected.to be_denied_for :visitor }
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /profile/history" do
|
describe "GET /profile/audit_log" do
|
||||||
subject { history_profile_path }
|
subject { audit_log_profile_path }
|
||||||
|
|
||||||
it { is_expected.to be_allowed_for @u1 }
|
it { is_expected.to be_allowed_for @u1 }
|
||||||
it { is_expected.to be_allowed_for :admin }
|
it { is_expected.to be_allowed_for :admin }
|
||||||
|
|
|
@ -108,8 +108,8 @@ describe ProfilesController, "routing" do
|
||||||
expect(get("/profile/account")).to route_to('profiles/accounts#show')
|
expect(get("/profile/account")).to route_to('profiles/accounts#show')
|
||||||
end
|
end
|
||||||
|
|
||||||
it "to #history" do
|
it "to #audit_log" do
|
||||||
expect(get("/profile/history")).to route_to('profiles#history')
|
expect(get("/profile/audit_log")).to route_to('profiles#audit_log')
|
||||||
end
|
end
|
||||||
|
|
||||||
it "to #reset_private_token" do
|
it "to #reset_private_token" do
|
||||||
|
|
Loading…
Reference in a new issue