diff options
| author | erdgeist <erdgeist@erdgeist.org> | 2026-08-02 23:55:38 +0200 |
|---|---|---|
| committer | erdgeist <erdgeist@erdgeist.org> | 2026-08-02 23:55:38 +0200 |
| commit | c63c4620d1c659712cec93e354e84f5017bd6254 (patch) | |
| tree | cb4b71768aab6d3b41c97f221ddbf06a148d8481 | |
| parent | 665bad61a26fd98b581c1f60981da94c05a28ca0 (diff) | |
Log user logins and reconstruct last activity from logs
| -rw-r--r-- | app/controllers/otp_challenges_controller.rb | 2 | ||||
| -rw-r--r-- | app/controllers/sessions_controller.rb | 1 | ||||
| -rw-r--r-- | db/migrate/20260802213023_add_last_login_to_users.rb | 5 | ||||
| -rw-r--r-- | lib/tasks/users.rake | 55 |
4 files changed, 63 insertions, 0 deletions
diff --git a/app/controllers/otp_challenges_controller.rb b/app/controllers/otp_challenges_controller.rb index 2526d1fb..5a834ed0 100644 --- a/app/controllers/otp_challenges_controller.rb +++ b/app/controllers/otp_challenges_controller.rb | |||
| @@ -28,6 +28,8 @@ class OtpChallengesController < ApplicationController | |||
| 28 | reset_session | 28 | reset_session |
| 29 | self.current_user = user | 29 | self.current_user = user |
| 30 | session[:logged_in_at] = Time.now.to_i | 30 | session[:logged_in_at] = Time.now.to_i |
| 31 | user.update_column(:last_login_at, Time.now) | ||
| 32 | |||
| 31 | # an admin who logs in and goes straight to user management | 33 | # an admin who logs in and goes straight to user management |
| 32 | # is already elevated | 34 | # is already elevated |
| 33 | elevate! if user.is_admin? | 35 | elevate! if user.is_admin? |
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index bb63c51e..00d19cdd 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb | |||
| @@ -30,6 +30,7 @@ class SessionsController < ApplicationController | |||
| 30 | else | 30 | else |
| 31 | self.current_user = user | 31 | self.current_user = user |
| 32 | session[:logged_in_at] = Time.now.to_i | 32 | session[:logged_in_at] = Time.now.to_i |
| 33 | user.update_column(:last_login_at, Time.now) | ||
| 33 | 34 | ||
| 34 | if user.otp_required? | 35 | if user.otp_required? |
| 35 | flash[:error] = t("flash.sessions.otp_setup_now") | 36 | flash[:error] = t("flash.sessions.otp_setup_now") |
diff --git a/db/migrate/20260802213023_add_last_login_to_users.rb b/db/migrate/20260802213023_add_last_login_to_users.rb new file mode 100644 index 00000000..6fec24a1 --- /dev/null +++ b/db/migrate/20260802213023_add_last_login_to_users.rb | |||
| @@ -0,0 +1,5 @@ | |||
| 1 | class AddLastLoginToUsers < ActiveRecord::Migration[8.1] | ||
| 2 | def change | ||
| 3 | add_column :users, :last_login_at, :datetime | ||
| 4 | end | ||
| 5 | end | ||
diff --git a/lib/tasks/users.rake b/lib/tasks/users.rake index 4a941e39..ee5da7dc 100644 --- a/lib/tasks/users.rake +++ b/lib/tasks/users.rake | |||
| @@ -22,4 +22,59 @@ namespace :users do | |||
| 22 | puts "Cleared the second factor for #{user.login}. " \ | 22 | puts "Cleared the second factor for #{user.login}. " \ |
| 23 | "They can re-enrol under My account." | 23 | "They can re-enrol under My account." |
| 24 | end | 24 | end |
| 25 | |||
| 26 | desc "Seed last_login_at from whatever the database still remembers. " \ | ||
| 27 | "There is no login history, so the column is reconstructed once " \ | ||
| 28 | "from the newest trace each account left: log entries, authorship, " \ | ||
| 29 | "editing, tagging. Accounts with no trace fall back to their own " \ | ||
| 30 | "created_at, which tells an ancient untraceable account apart from " \ | ||
| 31 | "one made yesterday. Dry run unless WRITE=1; FORCE=1 is required " \ | ||
| 32 | "once any real login has been recorded." | ||
| 33 | task :seed_last_login => :environment do | ||
| 34 | write = ENV["WRITE"] == "1" | ||
| 35 | force = ENV["FORCE"] == "1" | ||
| 36 | |||
| 37 | floor = nil | ||
| 38 | if ENV["FLOOR"].present? | ||
| 39 | floor = Time.zone.parse(ENV["FLOOR"]) or abort "FLOOR must be YYYY-MM-DD" | ||
| 40 | end | ||
| 41 | |||
| 42 | seeded = User.where.not(:last_login_at => nil).count | ||
| 43 | if seeded > 0 && write && !force | ||
| 44 | abort "#{seeded} accounts already carry a last_login_at, which may be " \ | ||
| 45 | "a real login. Re-run with FORCE=1 to overwrite them." | ||
| 46 | end | ||
| 47 | |||
| 48 | users = User.order(:login).to_a | ||
| 49 | ids = users.map(&:id) | ||
| 50 | |||
| 51 | newest = { | ||
| 52 | "log" => NodeAction.where(:user_id => ids).group(:user_id).maximum(:occurred_at), | ||
| 53 | "author" => Page.where(:user_id => ids).group(:user_id).maximum(:created_at), | ||
| 54 | "editor" => Page.where(:editor_id => ids).group(:editor_id).maximum(:updated_at), | ||
| 55 | "tag" => ActsAsTaggableOn::Tagging.where(:user_id => ids) | ||
| 56 | .group(:user_id).maximum(:created_at), | ||
| 57 | "tagger" => ActsAsTaggableOn::Tagging.where(:tagger_type => "User", :tagger_id => ids) | ||
| 58 | .group(:tagger_id).maximum(:created_at) | ||
| 59 | } | ||
| 60 | |||
| 61 | puts "DRY RUN -- nothing written. Re-run with WRITE=1." unless write | ||
| 62 | puts format("%-18s %-12s %-8s %s", "login", "seeded", "source", "roles") | ||
| 63 | |||
| 64 | users.each do |user| | ||
| 65 | clues = newest.transform_values { |by_id| by_id[user.id] }.compact | ||
| 66 | source, date = clues.max_by { |_, at| at } | ||
| 67 | source, date = "created", user.created_at if date.nil? | ||
| 68 | source, date = "floor", floor if date.nil? | ||
| 69 | |||
| 70 | if date.nil? | ||
| 71 | puts format("%-18s %-12s %-8s %s", user.login, "SKIPPED", "none", user.roles.join(",")) | ||
| 72 | next | ||
| 73 | end | ||
| 74 | |||
| 75 | user.update_columns(:last_login_at => date) if write | ||
| 76 | puts format("%-18s %-12s %-8s %s", user.login, date.to_date, source, user.roles.join(",")) | ||
| 77 | end | ||
| 78 | end | ||
| 79 | |||
| 25 | end | 80 | end |
