Procházet zdrojové kódy

Merge pull request #337 from troglodyne-bot/koan/session-audit-logs

feat: session audit log for auth events
George S. Baugh před 4 měsíci
rodič
revize
c0d02153fc

+ 58 - 5
lib/Trog/Auth.pm

@@ -256,7 +256,7 @@ Returns a session ID, or blank string in the event the user does not exist or in
 
 =cut
 
-sub mksession ( $user, $pass, $token ) {
+sub mksession ( $user, $pass, $token, $ip_addr = '' ) {
     my $dbh  = _dbh();
     my $totp = _totp();
 
@@ -268,6 +268,7 @@ sub mksession ( $user, $pass, $token ) {
     my $worked = $dbh->selectall_arrayref( "SELECT name, totp_secret FROM user WHERE hash=? AND name = ?", { Slice => {} }, $hash, $user );
     if ( !( ref $worked eq 'ARRAY' && @$worked ) ) {
         INFO("Failed login for user $user");
+        _log_event( 'login_failure', $user, $ip_addr );
         return '';
     }
     my $uid    = $worked->[0]{name};
@@ -275,19 +276,26 @@ sub mksession ( $user, $pass, $token ) {
 
     # Validate the 2FA Token.  If we have no secret, allow login so they can see their QR code, and subsequently re-auth.
     if ($secret) {
-        return '' unless $token;
+        if ( !$token ) {
+            _log_event( 'totp_failure', $user, $ip_addr );
+            return '';
+        }
         DEBUG( "TOTP Auth: Sent code $token, expect " . $totp->expected_totp_code(time) );
 
         #XXX we have to force the secret into compliance, otherwise it generates one on the fly, oof
         $totp->{secret} = $secret;
         my $rc = $totp->validate_otp( otp => $token, secret => $secret, tolerance => 3, period => 30, digits => 6 );
-        INFO("TOTP Auth failed for user $user") unless $rc;
-        return ''                               unless $rc;
+        if ( !$rc ) {
+            INFO("TOTP Auth failed for user $user");
+            _log_event( 'totp_failure', $user, $ip_addr );
+            return '';
+        }
     }
 
     # Issue cookie
     my $uuid = Trog::Utils::uuid();
     $dbh->do( "INSERT OR REPLACE INTO session (id,username) VALUES (?,?)", undef, $uuid, $uid ) or return '';
+    _log_event( 'login_success', $user, $ip_addr, $uuid );
     return $uuid;
 }
 
@@ -297,9 +305,10 @@ Delete the provided user's session from the auth db.
 
 =cut
 
-sub killsession ($user) {
+sub killsession ( $user, $ip_addr = '' ) {
     my $dbh = _dbh();
     $dbh->do( "DELETE FROM session WHERE username=?", undef, $user );
+    _log_event( 'logout', $user, $ip_addr );
     return 1;
 }
 
@@ -387,6 +396,50 @@ sub process_change_request ($token) {
     return $res;
 }
 
+=head2 audit_log(%opts) = ARRAYREF
+
+Return recent authentication events.
+Supports filtering by C<username>, C<event_type>, and C<limit> (default 100).
+
+=cut
+
+sub audit_log (%opts) {
+    my $dbh   = _dbh();
+    my $limit = int( $opts{limit} // 100 );
+    my @where;
+    my @bind;
+
+    if ( $opts{username} ) {
+        push @where, 'username = ?';
+        push @bind,  $opts{username};
+    }
+    if ( $opts{event_type} ) {
+        push @where, 'event_type = ?';
+        push @bind,  $opts{event_type};
+    }
+
+    my $where = @where ? 'WHERE ' . join( ' AND ', @where ) : '';
+    my $rows = $dbh->selectall_arrayref(
+        "SELECT id, event_time, username, session_id, ip_addr, event_type FROM audit_log $where ORDER BY event_time DESC LIMIT ?",
+        { Slice => {} }, @bind, $limit
+    );
+    return [] unless ref $rows eq 'ARRAY';
+    return $rows;
+}
+
+# Write one event row; silently swallows errors so logging never breaks auth flow.
+sub _log_event ( $event_type, $username, $ip_addr = '', $session_id = undef ) {
+    eval {
+        my $dbh = _dbh();
+        $dbh->do(
+            "INSERT INTO audit_log (event_time, username, session_id, ip_addr, event_type) VALUES (?,?,?,?,?)",
+            undef, time(), $username, $session_id, $ip_addr, $event_type
+        );
+    };
+    WARN("audit_log insert failed: $@") if $@;
+    return;
+}
+
 # Ensure the db schema is OK, and give us a handle
 sub _dbh {
     my $file   = 'schema/auth.schema';

+ 46 - 2
lib/Trog/Routes/HTML.pm

@@ -153,6 +153,13 @@ our %routes = (
         auth     => 1,
         callback => \&Trog::Routes::HTML::metrics,
     },
+    '/admin/sessions' => {
+        method   => 'GET',
+        auth     => 1,
+        callback => \&Trog::Routes::HTML::sessions,
+        noindex  => 1,
+        nocache  => 1,
+    },
 
     #TODO transform into posts?
     '/sitemap',
@@ -480,7 +487,7 @@ sub login ($query) {
         }
 
         $query->{failed} = 1;
-        my $cookie = Trog::Auth::mksession( $query->{username}, $query->{password}, $query->{token} );
+        my $cookie = Trog::Auth::mksession( $query->{username}, $query->{password}, $query->{token}, $query->{ip} // '' );
         if ($cookie) {
 
             # TODO secure / sameSite cookie to kill csrf, maybe do rememberme with Expires=~0
@@ -593,7 +600,7 @@ Deletes your users' session and opens the index.
 =cut
 
 sub logout ($query) {
-    Trog::Auth::killsession( $query->{user} ) if $query->{user};
+    Trog::Auth::killsession( $query->{user}, $query->{ip} // '' ) if $query->{user};
     delete $query->{user};
     return Trog::Routes::HTML::index($query);
 }
@@ -1585,6 +1592,43 @@ sub metrics ($query) {
     );
 }
 
+sub sessions ($query) {
+    return $query->{tpsgi}->see_also('/login')  unless $query->{user};
+    return $query->{tpsgi}->forbidden($query) unless grep { $_ eq 'admin' } @{ $query->{user_acls} };
+
+    my $limit  = int( $query->{limit} // 100 );
+    my $events = Trog::Auth::audit_log(
+        limit      => $limit,
+        username   => $query->{filter_user}  // '',
+        event_type => $query->{filter_event} // '',
+    );
+
+    # Format timestamps for display
+    @$events = map {
+        my $e = {%$_};
+        my @t = localtime( $e->{event_time} );
+        $e->{event_ts} = sprintf( '%04d-%02d-%02d %02d:%02d:%02d', $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0] );
+        $e->{session_short} = $e->{session_id} ? substr( $e->{session_id}, 0, 8 ) . '...' : '';
+        $e
+    } @$events;
+
+    return Trog::Routes::HTML::index(
+        {
+            title        => 'tCMS Session Audit Log',
+            theme_dir    => Trog::Themes::td(),
+            template     => 'sessions.tx',
+            is_admin     => 1,
+            events       => $events,
+            limit        => $limit,
+            filter_user  => $query->{filter_user}  // '',
+            filter_event => $query->{filter_event} // '',
+            %$query,
+        },
+        undef,
+        ['post.css'],
+    );
+}
+
 # basically a file rewrite rule for themes
 sub icon ($query) {
     my $path = $query->{route};

+ 12 - 0
schema/auth.schema

@@ -30,3 +30,15 @@ CREATE TABLE IF NOT EXISTS change_request (
 );
 
 CREATE VIEW IF NOT EXISTS change_request_full AS SELECT cr.username, u.display_name, cr.type, cr.token, cr.secret, cr.processed, u.contact_email from change_request AS cr JOIN user AS u ON u.name=cr.username;
+
+CREATE TABLE IF NOT EXISTS audit_log (
+    id         INTEGER PRIMARY KEY AUTOINCREMENT,
+    event_time INTEGER NOT NULL,
+    username   TEXT    NOT NULL,
+    session_id TEXT    DEFAULT NULL,
+    ip_addr    TEXT    NOT NULL DEFAULT '',
+    event_type TEXT    NOT NULL
+);
+
+CREATE INDEX IF NOT EXISTS audit_log_time_idx ON audit_log(event_time);
+CREATE INDEX IF NOT EXISTS audit_log_user_idx ON audit_log(username);

+ 43 - 0
www/templates/html/components/sessions.tx

@@ -0,0 +1,43 @@
+<div id="backoffice">
+    <h2>Session Audit Log</h2>
+    <form method="GET" action="/admin/sessions">
+        <label>User: <input class="cooltext" type="text" name="filter_user" value="<: $filter_user :>" placeholder="(all)" /></label>
+        <label>Event:
+            <select class="cooltext" name="filter_event">
+                <option value="">All</option>
+                : for ['login_success','login_failure','totp_failure','logout'] -> $ev {
+                <option value="<: $ev :>" <: $ev eq $filter_event ? 'selected' : '' | mark_raw :>><: $ev :></option>
+                : }
+            </select>
+        </label>
+        <label>Limit: <input class="cooltext" type="number" name="limit" value="<: $limit :>" min="10" max="1000" /></label>
+        <input class="coolbutton" type="submit" value="Filter" />
+    </form>
+    <br />
+    : if ($events) {
+    <table class="cooltext" style="width:100%;border-collapse:collapse;">
+        <thead>
+            <tr style="text-align:left;">
+                <th style="padding:4px 8px;">Time (local)</th>
+                <th style="padding:4px 8px;">Event</th>
+                <th style="padding:4px 8px;">User</th>
+                <th style="padding:4px 8px;">IP Address</th>
+                <th style="padding:4px 8px;">Session</th>
+            </tr>
+        </thead>
+        <tbody>
+            : for $events -> $ev {
+            <tr>
+                <td style="padding:4px 8px;white-space:nowrap;"><: $ev.event_ts :></td>
+                <td style="padding:4px 8px;"><: $ev.event_type :></td>
+                <td style="padding:4px 8px;"><: $ev.username :></td>
+                <td style="padding:4px 8px;font-family:monospace;"><: $ev.ip_addr :></td>
+                <td style="padding:4px 8px;font-family:monospace;font-size:0.85em;"><: $ev.session_short :></td>
+            </tr>
+            : }
+        </tbody>
+    </table>
+    : } else {
+    <p>No events recorded yet.</p>
+    : }
+</div>

+ 1 - 0
www/templates/html/sysbar.tx

@@ -5,6 +5,7 @@
         <a href="/config"      title="Configuration" class="topbar">Settings</a>
         <a href="/manual"      title="Manual"        class="topbar">Manual</a>
         <a href="/metrics?period=hour&num_periods=24"     title="Metrics"       class="topbar">Metrics</a>
+        <a href="/admin/sessions"  title="Session Audit Log" class="topbar">Sessions</a>
         <a href="/totp"        title="TOTP"          class="topbar">Enable/View TOTP 2fa</a>
         <a href="/password_reset" title="Reset Auth" class="topbar">Reset Password/TOTP</a>
         <a href="/logout"      title="Logout"        class="topbar">🚪</a>