diff --git a/CHANGELOG b/CHANGELOG index 616b41a4269a7f3c606d88b17cd6abe1e5b1e6f1..ca193485149e694c52eda65d226f500c715b1dfa 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 - 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 - - 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 - Fix error when deleting a user who has projects (Stan Hu) diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 765adaf2128ebabc5528d84bce68d2568c80e09d..fd51b380da21d09bbf87f539e6f0175eba289e74 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -28,6 +28,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # Do additional LDAP checks for the user filter and EE features if @user.allowed? + log_audit_event(gl_user, with: :ldap) sign_in_and_redirect(gl_user) else flash[:alert] = "Access denied for your LDAP account." @@ -47,6 +48,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController if current_user # Add new authentication method 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' else @user = Gitlab::OAuth::User.new(oauth) @@ -54,6 +56,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController # Only allow properly saved users to login. if @user.persisted? && @user.valid? + log_audit_event(@user.gl_user, with: oauth['provider']) sign_in_and_redirect(@user.gl_user) else error_message = @@ -83,4 +86,9 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def oauth @oauth ||= request.env['omniauth.auth'] end + + def log_audit_event(user, options = {}) + AuditEventService.new(user, user, options). + for_authentication.security_event + end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index b4af9e490ed43936ae1c587b634fc46e6771d9ed..c42b2caf2b80e3e0ba5556d2de6c013a3764b471 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -37,8 +37,11 @@ class ProfilesController < Profiles::ApplicationController redirect_to profile_account_path end - def history - @events = current_user.recent_events.page(params[:page]).per(PER_PAGE) + def audit_log + @events = AuditEvent.where(entity_type: "User", entity_id: current_user.id). + order("created_at DESC"). + page(params[:page]). + per(PER_PAGE) end def update_username diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 7577fc96d6d7035a862e1a33b3de8c470d7661f8..89629bc0581e82d1e194f37118358038ccbc5c5b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -37,6 +37,8 @@ class SessionsController < Devise::SessionsController resource.update_attributes(reset_password_token: nil, reset_password_sent_at: nil) end + authenticated_with = user_params[:otp_attempt] ? "two-factor" : "standard" + log_audit_event(current_user, with: authenticated_with) end end @@ -95,4 +97,9 @@ class SessionsController < Devise::SessionsController user.valid_otp?(user_params[:otp_attempt]) || user.invalidate_otp_backup_code!(user_params[:otp_attempt]) end + + def log_audit_event(user, options = {}) + AuditEventService.new(user, user, options). + for_authentication.security_event + end end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..967ffd46db0832efd59ac3624a39e2ce268c05b0 --- /dev/null +++ b/app/models/audit_event.rb @@ -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 diff --git a/app/models/security_event.rb b/app/models/security_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..d131c11cb6c6d7dea146141887f78755c4fe28bb --- /dev/null +++ b/app/models/security_event.rb @@ -0,0 +1,2 @@ +class SecurityEvent < AuditEvent +end diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a7f090655e15d82854c80ec76743af576188443d --- /dev/null +++ b/app/services/audit_event_service.rb @@ -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 diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index 914e1b83d1ff24a5eb067b932f4f58bbb13b8690..de5544268a147403b7541fffa9c86c969cb04971 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -44,8 +44,8 @@ = icon('image fw') %span Preferences - = nav_link(path: 'profiles#history') do - = link_to history_profile_path, title: 'History', data: {placement: 'right'} do + = nav_link(path: 'profiles#audit_log') do + = link_to audit_log_profile_path, title: 'Audit Log', data: {placement: 'right'} do = icon('history fw') %span - History + Audit Log diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..c19ac429d52da7943f6197cce52f4c8e8b38002e --- /dev/null +++ b/app/views/profiles/_event_table.html.haml @@ -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" diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..698d603742811d0eb5c5fe0d42afd6f71d7dae60 --- /dev/null +++ b/app/views/profiles/audit_log.html.haml @@ -0,0 +1,5 @@ +- page_title "Audit Log" +%h3.page-title Audit Log +%p.light History of authentications + += render 'event_table', events: @events \ No newline at end of file diff --git a/app/views/profiles/history.html.haml b/app/views/profiles/history.html.haml deleted file mode 100644 index b414fb69f4ed3126d4800244283a8ee30bc1be3c..0000000000000000000000000000000000000000 --- a/app/views/profiles/history.html.haml +++ /dev/null @@ -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" - diff --git a/config/routes.rb b/config/routes.rb index 33f55dde476f5953138b7679219bbeacffb44e60..b2ac8dac36ac325a5afe46b3309945a6324d311d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -207,7 +207,7 @@ Gitlab::Application.routes.draw do # resource :profile, only: [:show, :update] do member do - get :history + get :audit_log get :applications put :reset_private_token diff --git a/db/migrate/20141118150935_add_audit_event.rb b/db/migrate/20141118150935_add_audit_event.rb new file mode 100644 index 0000000000000000000000000000000000000000..07383c6bbc761b1bae2cab32edc627d590e85f3a --- /dev/null +++ b/db/migrate/20141118150935_add_audit_event.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 3a5af6a76d46c610d9c928b2185f59d35bc83c95..8736d1e0df5257558edae71fd42b83be39371c5d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -28,16 +28,30 @@ ActiveRecord::Schema.define(version: 20150620233230) do t.integer "default_branch_protection", default: 2 t.boolean "twitter_sharing_enabled", default: true t.text "restricted_visibility_levels" - t.boolean "version_check_enabled", default: true t.integer "max_attachment_size", default: 10, null: false t.integer "default_project_visibility" t.integer "default_snippet_visibility" t.text "restricted_signup_domains" + t.boolean "version_check_enabled", default: true t.boolean "user_oauth_applications", default: true t.string "after_sign_out_path" t.integer "session_expire_delay", default: 10080, null: false 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| t.text "message", null: false t.datetime "starts_at" @@ -496,12 +510,12 @@ ActiveRecord::Schema.define(version: 20150620233230) do t.string "bitbucket_access_token" t.string "bitbucket_access_token_secret" t.string "location" + t.string "public_email", default: "", null: false t.string "encrypted_otp_secret" t.string "encrypted_otp_secret_iv" t.string "encrypted_otp_secret_salt" t.boolean "otp_required_for_login", default: false, null: false t.text "otp_backup_codes" - t.string "public_email", default: "", null: false t.integer "dashboard", default: 0 end diff --git a/features/profile/active_tab.feature b/features/profile/active_tab.feature index 1fa4ac88ddc9187dc4964e8989c00d400c84ccad..788b7895d72833b8a1bdb7f0631c802cef8797e5 100644 --- a/features/profile/active_tab.feature +++ b/features/profile/active_tab.feature @@ -23,7 +23,7 @@ Feature: Profile Active Tab Then the active main tab should be Preferences And no other main tabs should be active - Scenario: On Profile History - Given I visit profile history page - Then the active main tab should be History + Scenario: On Profile Audit Log + Given I visit Audit Log page + Then the active main tab should be Audit Log And no other main tabs should be active diff --git a/features/profile/profile.feature b/features/profile/profile.feature index 0dd0afde8b1015bd41cc8d687514ca91230ea208..7a1345f2b377e3dff5039a10c581d2cae8d2666a 100644 --- a/features/profile/profile.feature +++ b/features/profile/profile.feature @@ -63,7 +63,7 @@ Feature: Profile Scenario: I visit history tab Given I have activity - When I visit profile history page + When I visit Audit Log page Then I should see my activity Scenario: I visit my user page diff --git a/features/steps/profile/active_tab.rb b/features/steps/profile/active_tab.rb index 79e3b55f6e1083671725ce7115740681e3ab3aa9..4724a32627778377b4d1ec08a4a384004b138330 100644 --- a/features/steps/profile/active_tab.rb +++ b/features/steps/profile/active_tab.rb @@ -19,7 +19,7 @@ class Spinach::Features::ProfileActiveTab < Spinach::FeatureSteps ensure_active_main_tab('Preferences') end - step 'the active main tab should be History' do - ensure_active_main_tab('History') + step 'the active main tab should be Audit Log' do + ensure_active_main_tab('Audit Log') end end diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index 11e1163c3520eca22ea65a28ac9d538a1d70a87c..2b6b8b167f600960f31228b192e07907738b62c6 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -115,7 +115,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end 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 step 'my password is expired' do diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 4cc01443c8b9ffc76e006850e73b6069847ff557..fe651e81dac60b8c9cf3572b21cec54f722fd2f7 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -127,8 +127,8 @@ module SharedPaths visit profile_preferences_path end - step 'I visit profile history page' do - visit history_profile_path + step 'I visit Audit Log page' do + visit audit_log_profile_path end # ---------------------------------------- diff --git a/spec/features/security/profile_access_spec.rb b/spec/features/security/profile_access_spec.rb index 8f7a9606262b1827a5f5156eee2d5e36f2b34ed7..bcabc2d53ac9d200cb0d0b1ba8f92579cd2cbb9f 100644 --- a/spec/features/security/profile_access_spec.rb +++ b/spec/features/security/profile_access_spec.rb @@ -45,8 +45,8 @@ describe "Profile access", feature: true do it { is_expected.to be_denied_for :visitor } end - describe "GET /profile/history" do - subject { history_profile_path } + describe "GET /profile/audit_log" do + subject { audit_log_profile_path } it { is_expected.to be_allowed_for @u1 } it { is_expected.to be_allowed_for :admin } diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb index 0fda6202a11b7536cef57899792a66719c9921ad..dd045826692c13683087190b5f0f284ffd1e4129 100644 --- a/spec/routing/routing_spec.rb +++ b/spec/routing/routing_spec.rb @@ -108,8 +108,8 @@ describe ProfilesController, "routing" do expect(get("/profile/account")).to route_to('profiles/accounts#show') end - it "to #history" do - expect(get("/profile/history")).to route_to('profiles#history') + it "to #audit_log" do + expect(get("/profile/audit_log")).to route_to('profiles#audit_log') end it "to #reset_private_token" do