You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2021/07/16 23:04:45 UTC

[trafficcontrol] branch master updated: Logic to capture/display the last time a user successfully authenticated (#6009)

This is an automated email from the ASF dual-hosted git repository.

ocket8888 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new b7ced4a  Logic to capture/display the last time a user successfully authenticated (#6009)
b7ced4a is described below

commit b7ced4a8c1afe439eec8805e23a11957db822e6f
Author: Rima Shah <22...@users.noreply.github.com>
AuthorDate: Fri Jul 16 17:04:31 2021 -0600

    Logic to capture/display the last time a user successfully authenticated (#6009)
    
    * Added logic for last_authenticated in TO
    
    * Added TP changes.
    
    * updated CHANGELOG.md
    
    * updated docs for users
    
    * updated user tests
    
    * updated sql file
    
    * updated time
    
    * updated based on review comments.
    
    * updated based on review comments - 1
    
    * updated docs
    
    * updated logic for relativeLoginTime
---
 CHANGELOG.md                                       |  1 +
 docs/source/api/v4/user_current.rst                | 48 ++++++++++----------
 docs/source/api/v4/users.rst                       | 52 +++++++++++-----------
 docs/source/api/v4/users_id.rst                    | 48 ++++++++++----------
 lib/go-tc/users.go                                 | 38 +++++++++++-----
 ...070800000000_add_tm_user_last_authenticated.sql | 26 +++++++++++
 traffic_ops/testing/api/v4/user_test.go            |  9 ++++
 traffic_ops/traffic_ops_golang/login/login.go      | 24 ++++++++--
 traffic_ops/traffic_ops_golang/user/current.go     | 22 ++++++---
 traffic_ops/traffic_ops_golang/user/user.go        |  4 +-
 traffic_ops/v4-client/user.go                      |  7 ++-
 .../TableCapabilityUsersController.js              |  8 ++--
 .../capabilityUsers/table.capabilityUsers.tpl.html |  2 +
 .../table/roleUsers/TableRoleUsersController.js    |  8 ++--
 .../table/roleUsers/table.roleUsers.tpl.html       |  2 +
 .../tenantUsers/TableTenantUsersController.js      |  8 ++--
 .../table/tenantUsers/table.tenantUsers.tpl.html   |  2 +
 .../modules/table/users/TableUsersController.js    | 11 +++--
 .../modules/table/users/table.users.tpl.html       |  6 +--
 .../app/src/common/service/utils/DateUtils.js      | 22 +++++++--
 20 files changed, 234 insertions(+), 114 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3e12c7..fd6ccdf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 
 ## [unreleased]
 ### Added
+- [#5412](https://github.com/apache/trafficcontrol/issues/5412) Added last authenticated time to user API's (`GET /user/current, GET /users, GET /user?id=`) response payload
 - [#5451](https://github.com/apache/trafficcontrol/issues/5451) Added change log count to user API's response payload and query param (username) to logs API
 - Added support for CDN locks
 - Added support for PostgreSQL as a Traffic Vault backend
diff --git a/docs/source/api/v4/user_current.rst b/docs/source/api/v4/user_current.rst
index 953a48c..b51de38 100644
--- a/docs/source/api/v4/user_current.rst
+++ b/docs/source/api/v4/user_current.rst
@@ -35,28 +35,29 @@ No parameters available.
 
 Response Structure
 ------------------
-:addressLine1:     The user's address - including street name and number
-:addressLine2:     An additional address field for e.g. apartment number
-:city:             The name of the city wherein the user resides
-:company:          The name of the company for which the user works
-:country:          The name of the country wherein the user resides
-:email:            The user's email address
-:fullName:         The user's full name, e.g. "John Quincy Adams"
-:gid:              A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX group ID of the user
-:id:               An integral, unique identifier for this user
-:lastUpdated:      The date and time at which the user was last modified, in :ref:`non-rfc-datetime`
-:newUser:          A meta field with no apparent purpose that is usually ``null`` unless explicitly set during creation or modification of a user via some API endpoint
-:phoneNumber:      The user's phone number
-:postalCode:       The postal code of the area in which the user resides
-:publicSshKey:     The user's public key used for the SSH protocol
-:registrationSent: If the user was created using the :ref:`to-api-users-register` endpoint, this will be the date and time at which the registration email was sent - otherwise it will be ``null``
-:role:             The integral, unique identifier of the highest-privilege :term:`Role` assigned to this user
-:rolename:         The name of the highest-privilege :term:`Role` assigned to this user
-:stateOrProvince:  The name of the state or province where this user resides
-:tenant:           The name of the :term:`Tenant` to which this user belongs
-:tenantId:         The integral, unique identifier of the :term:`Tenant` to which this user belongs
-:uid:              A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX user ID of the user
-:username:         The user's username
+:addressLine1:      The user's address - including street name and number
+:addressLine2:      An additional address field for e.g. apartment number
+:city:              The name of the city wherein the user resides
+:company:           The name of the company for which the user works
+:country:           The name of the country wherein the user resides
+:email:             The user's email address
+:fullName:          The user's full name, e.g. "John Quincy Adams"
+:gid:               A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX group ID of the user
+:id:                An integral, unique identifier for this user
+:lastAuthenticated: The date and time at which the user was last authenticated, in :rfc:`3339`
+:lastUpdated:       The date and time at which the user was last modified, in :ref:`non-rfc-datetime`
+:newUser:           A meta field with no apparent purpose that is usually ``null`` unless explicitly set during creation or modification of a user via some API endpoint
+:phoneNumber:       The user's phone number
+:postalCode:        The postal code of the area in which the user resides
+:publicSshKey:      The user's public key used for the SSH protocol
+:registrationSent:  If the user was created using the :ref:`to-api-users-register` endpoint, this will be the date and time at which the registration email was sent - otherwise it will be ``null``
+:role:              The integral, unique identifier of the highest-privilege :term:`Role` assigned to this user
+:rolename:          The name of the highest-privilege :term:`Role` assigned to this user
+:stateOrProvince:   The name of the state or province where this user resides
+:tenant:            The name of the :term:`Tenant` to which this user belongs
+:tenantId:          The integral, unique identifier of the :term:`Tenant` to which this user belongs
+:uid:               A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX user ID of the user
+:username:          The user's username
 
 .. code-block:: http
 	:caption: Response Example
@@ -95,7 +96,8 @@ Response Structure
 		"tenant": "root",
 		"tenantId": 1,
 		"uid": null,
-		"lastUpdated": "2018-12-12 16:26:32+00"
+		"lastUpdated": "2018-12-12 16:26:32+00",
+		"lastAuthenticated": "2021-07-09T14:44:10.371708-06:00"
 	}}
 
 ``PUT``
diff --git a/docs/source/api/v4/users.rst b/docs/source/api/v4/users.rst
index cbf780b..658e191 100644
--- a/docs/source/api/v4/users.rst
+++ b/docs/source/api/v4/users.rst
@@ -68,29 +68,30 @@ Request Structure
 
 Response Structure
 ------------------
-:addressLine1:     The user's address - including street name and number
-:addressLine2:     An additional address field for e.g. apartment number
-:city:             The name of the city wherein the user resides
-:company:          The name of the company for which the user works
-:country:          The name of the country wherein the user resides
-:email:            The user's email address
-:fullName:         The user's full name, e.g. "John Quincy Adams"
-:gid:              A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX group ID of the user - now it is always ``null``
-:id:               An integral, unique identifier for this user
-:lastUpdated:      The date and time at which the user was last modified, in :ref:`non-rfc-datetime`
-:newUser:          A meta field with no apparent purpose that is usually ``null`` unless explicitly set during creation or modification of a user via some API endpoint
-:phoneNumber:      The user's phone number
-:postalCode:       The postal code of the area in which the user resides
-:publicSshKey:     The user's public key used for the SSH protocol
-:registrationSent: If the user was created using the :ref:`to-api-users-register` endpoint, this will be the date and time at which the registration email was sent - otherwise it will be ``null``
-:role:             The integral, unique identifier of the highest-privilege role assigned to this user
-:rolename:         The name of the highest-privilege role assigned to this user
-:stateOrProvince:  The name of the state or province where this user resides
-:tenant:           The name of the tenant to which this user belongs
-:tenantId:         The integral, unique identifier of the tenant to which this user belongs
-:uid:              A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX user ID of the user - now it is always ``null``
-:username:         The user's username
-:changeLogCount:   The number of change log entries created by the user
+:addressLine1:      The user's address - including street name and number
+:addressLine2:      An additional address field for e.g. apartment number
+:changeLogCount:    The number of change log entries created by the user
+:city:              The name of the city wherein the user resides
+:company:           The name of the company for which the user works
+:country:           The name of the country wherein the user resides
+:email:             The user's email address
+:fullName:          The user's full name, e.g. "John Quincy Adams"
+:gid:               A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX group ID of the user - now it is always ``null``
+:id:                An integral, unique identifier for this user
+:lastAuthenticated: The date and time at which the user was last authenticated, in :rfc:`3339`
+:lastUpdated:       The date and time at which the user was last modified, in :ref:`non-rfc-datetime`
+:newUser:           A meta field with no apparent purpose that is usually ``null`` unless explicitly set during creation or modification of a user via some API endpoint
+:phoneNumber:       The user's phone number
+:postalCode:        The postal code of the area in which the user resides
+:publicSshKey:      The user's public key used for the SSH protocol
+:registrationSent:  If the user was created using the :ref:`to-api-users-register` endpoint, this will be the date and time at which the registration email was sent - otherwise it will be ``null``
+:role:              The integral, unique identifier of the highest-privilege role assigned to this user
+:rolename:          The name of the highest-privilege role assigned to this user
+:stateOrProvince:   The name of the state or province where this user resides
+:tenant:            The name of the tenant to which this user belongs
+:tenantId:          The integral, unique identifier of the tenant to which this user belongs
+:uid:               A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX user ID of the user - now it is always ``null``
+:username:          The user's username
 
 .. code-block:: http
 	:caption: Response Example
@@ -130,8 +131,9 @@ Response Structure
 			"tenant": "root",
 			"tenantId": 1,
 			"uid": null,
-			"lastUpdated": "2018-12-12 16:26:32+00"
-			"changeLogCount":	20
+			"lastUpdated": "2018-12-12 16:26:32+00",
+			"changeLogCount": 20,
+			"lastAuthenticated": "2021-07-09T14:44:10.371708-06:00"
 		}
 	]}
 
diff --git a/docs/source/api/v4/users_id.rst b/docs/source/api/v4/users_id.rst
index 8a299a9..e4dac10 100644
--- a/docs/source/api/v4/users_id.rst
+++ b/docs/source/api/v4/users_id.rst
@@ -48,28 +48,29 @@ Request Structure
 
 Response Structure
 ------------------
-:addressLine1:     The user's address - including street name and number
-:addressLine2:     An additional address field for e.g. apartment number
-:city:             The name of the city wherein the user resides
-:company:          The name of the company for which the user works
-:country:          The name of the country wherein the user resides
-:email:            The user's email address
-:fullName:         The user's full name, e.g. "John Quincy Adams"
-:gid:              A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX group ID of the user - now it is always ``null``
-:id:               An integral, unique identifier for this user
-:lastUpdated:      The date and time at which the user was last modified, in :ref:`non-rfc-datetime`
-:newUser:          A meta field with no apparent purpose that is usually ``null`` unless explicitly set during creation or modification of a user via some API endpoint
-:phoneNumber:      The user's phone number
-:postalCode:       The postal code of the area in which the user resides
-:publicSshKey:     The user's public key used for the SSH protocol
-:registrationSent: If the user was created using the :ref:`to-api-users-register` endpoint, this will be the date and time at which the registration email was sent - otherwise it will be ``null``
-:role:             The integral, unique identifier of the highest-privilege role assigned to this user
-:rolename:         The name of the highest-privilege role assigned to this user
-:stateOrProvince:  The name of the state or province where this user resides
-:tenant:           The name of the tenant to which this user belongs
-:tenantId:         The integral, unique identifier of the tenant to which this user belongs
-:uid:              A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX user ID of the user - now it is always ``null``
-:username:         The user's username
+:addressLine1:      The user's address - including street name and number
+:addressLine2:      An additional address field for e.g. apartment number
+:city:              The name of the city wherein the user resides
+:company:           The name of the company for which the user works
+:country:           The name of the country wherein the user resides
+:email:             The user's email address
+:fullName:          The user's full name, e.g. "John Quincy Adams"
+:gid:               A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX group ID of the user - now it is always ``null``
+:id:                An integral, unique identifier for this user
+:lastAuthenticated: The date and time at which the user was last authenticated, in :rfc:`3339`
+:lastUpdated:       The date and time at which the user was last modified, in :ref:`non-rfc-datetime`
+:newUser:           A meta field with no apparent purpose that is usually ``null`` unless explicitly set during creation or modification of a user via some API endpoint
+:phoneNumber:       The user's phone number
+:postalCode:        The postal code of the area in which the user resides
+:publicSshKey:      The user's public key used for the SSH protocol
+:registrationSent:  If the user was created using the :ref:`to-api-users-register` endpoint, this will be the date and time at which the registration email was sent - otherwise it will be ``null``
+:role:              The integral, unique identifier of the highest-privilege role assigned to this user
+:rolename:          The name of the highest-privilege role assigned to this user
+:stateOrProvince:   The name of the state or province where this user resides
+:tenant:            The name of the tenant to which this user belongs
+:tenantId:          The integral, unique identifier of the tenant to which this user belongs
+:uid:               A deprecated field only kept for legacy compatibility reasons that used to contain the UNIX user ID of the user - now it is always ``null``
+:username:          The user's username
 
 .. code-block:: http
 	:caption: Response Example
@@ -109,7 +110,8 @@ Response Structure
 			"tenant": "root",
 			"tenantId": 1,
 			"uid": null,
-			"lastUpdated": "2018-12-13 17:24:23+00"
+			"lastUpdated": "2018-12-13 17:24:23+00",
+			"lastAuthenticated": "2021-07-09T14:44:10.371708-06:00"
 		}
 	]}
 
diff --git a/lib/go-tc/users.go b/lib/go-tc/users.go
index 773d544..1bf0388 100644
--- a/lib/go-tc/users.go
+++ b/lib/go-tc/users.go
@@ -19,16 +19,19 @@ package tc
  * under the License.
  */
 
-import "database/sql"
-import "encoding/json"
-import "errors"
-import "fmt"
+import (
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
 
-import "github.com/apache/trafficcontrol/lib/go-rfc"
-import "github.com/apache/trafficcontrol/lib/go-util"
+	"github.com/apache/trafficcontrol/lib/go-rfc"
+	"github.com/apache/trafficcontrol/lib/go-util"
 
-import "github.com/go-ozzo/ozzo-validation"
-import "github.com/go-ozzo/ozzo-validation/is"
+	"github.com/go-ozzo/ozzo-validation"
+	"github.com/go-ozzo/ozzo-validation/is"
+)
 
 // UserCredentials contains Traffic Ops login credentials
 type UserCredentials struct {
@@ -107,13 +110,14 @@ type User struct {
 	commonUserFields
 }
 
-// UserV40 contains ChangeLogCount field
+// UserV40 contains ChangeLogCount field.
 type UserV40 struct {
 	User
-	ChangeLogCount *int `json:"changeLogCount" db:"change_log_count"`
+	ChangeLogCount    *int       `json:"changeLogCount" db:"change_log_count"`
+	LastAuthenticated *time.Time `json:"lastAuthenticated" db:"last_authenticated"`
 }
 
-// UserCurrent represents the profile for the authenticated user
+// UserCurrent represents the profile for the authenticated user.
 type UserCurrent struct {
 	UserName  *string `json:"username"`
 	LocalUser *bool   `json:"localUser"`
@@ -121,6 +125,12 @@ type UserCurrent struct {
 	commonUserFields
 }
 
+// UserCurrentV40 contains LastAuthenticated field.
+type UserCurrentV40 struct {
+	UserCurrent
+	LastAuthenticated *time.Time `json:"lastAuthenticated" db:"last_authenticated"`
+}
+
 // CurrentUserUpdateRequest differs from a regular User/UserCurrent in that many of its fields are
 // *parsed* but not *unmarshaled*. This allows a handler to distinguish between "null" and
 // "undefined" values.
@@ -331,6 +341,12 @@ type UserCurrentResponse struct {
 	Alerts
 }
 
+// UserCurrentResponseV40 is the Traffic Ops API version 4.0 variant of UserResponse.
+type UserCurrentResponseV40 struct {
+	Response UserCurrentV40 `json:"response"`
+	Alerts
+}
+
 // UserDeliveryServiceDeleteResponse can hold a Traffic Ops API response to
 // a request to remove a delivery service from a user.
 type UserDeliveryServiceDeleteResponse struct {
diff --git a/traffic_ops/app/db/migrations/2021070800000000_add_tm_user_last_authenticated.sql b/traffic_ops/app/db/migrations/2021070800000000_add_tm_user_last_authenticated.sql
new file mode 100644
index 0000000..1f147b9
--- /dev/null
+++ b/traffic_ops/app/db/migrations/2021070800000000_add_tm_user_last_authenticated.sql
@@ -0,0 +1,26 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership.  The ASF
+ * licenses this file to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+ * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+ * License for the specific language governing permissions and limitations under
+ * the License.
+ */
+
+-- +goose Up
+-- SQL in section 'Up' is executed when this migration is applied
+
+ALTER TABLE tm_user ADD COLUMN last_authenticated timestamp with time zone;
+
+-- +goose Down
+-- SQL section 'Down' is executed when this migration is rolled back
+
+ALTER TABLE tm_user DROP COLUMN last_authenticated;
diff --git a/traffic_ops/testing/api/v4/user_test.go b/traffic_ops/testing/api/v4/user_test.go
index 98b648d..af7d7a4 100644
--- a/traffic_ops/testing/api/v4/user_test.go
+++ b/traffic_ops/testing/api/v4/user_test.go
@@ -454,6 +454,12 @@ func GetTestUsers(t *testing.T) {
 	if err != nil {
 		t.Errorf("cannot get users: %v - alerts: %+v", err, resp.Alerts)
 	}
+	if len(resp.Response) < 1 {
+		t.Fatalf("expected a users list, got nothing")
+	}
+	if resp.Response[0].LastAuthenticated == nil {
+		t.Errorf("current user's authenticated time, expected: '%s' actual: %v", resp.Response[0].LastAuthenticated, nil)
+	}
 }
 
 func GetTestUserCurrent(t *testing.T) {
@@ -466,6 +472,9 @@ func GetTestUserCurrent(t *testing.T) {
 	} else if *user.Response.UserName != SessionUserName {
 		t.Errorf("current user expected: '%s' actual: '%s'", SessionUserName, *user.Response.UserName)
 	}
+	if user.Response.LastAuthenticated == nil {
+		t.Errorf("current user's authenticated time, expected: '%s' actual: %v", user.Response.LastAuthenticated, nil)
+	}
 }
 
 func UserTenancyTest(t *testing.T) {
diff --git a/traffic_ops/traffic_ops_golang/login/login.go b/traffic_ops/traffic_ops_golang/login/login.go
index 2f32ed5..5173ff6 100644
--- a/traffic_ops/traffic_ops_golang/login/login.go
+++ b/traffic_ops/traffic_ops_golang/login/login.go
@@ -62,6 +62,7 @@ WHERE name='tm.instance_name' AND
 `
 const userQueryByEmail = `SELECT EXISTS(SELECT * FROM tm_user WHERE email=$1)`
 const setTokenQuery = `UPDATE tm_user SET token=$1 WHERE email=$2`
+const UpdateLoginTimeQuery = `UPDATE tm_user SET last_authenticated = now() WHERE username=$1`
 
 const defaultCookieDuration = 6 * time.Hour
 
@@ -149,9 +150,26 @@ func LoginHandler(db *sqlx.DB, cfg config.Config) http.HandlerFunc {
 			if authenticated {
 				httpCookie := tocookie.GetCookie(form.Username, defaultCookieDuration, cfg.Secrets[0])
 				http.SetCookie(w, httpCookie)
-				resp = struct {
-					tc.Alerts
-				}{tc.CreateAlerts(tc.SuccessLevel, "Successfully logged in.")}
+
+				//If all's well until here, then update last authenticated time
+				tx, txErr := db.Begin()
+				if txErr != nil {
+					api.HandleErr(w, r, tx, http.StatusInternalServerError, nil, fmt.Errorf("beginning transaction: %w", txErr))
+					return
+				}
+				defer tx.Commit()
+				_, dbErr := tx.Exec(UpdateLoginTimeQuery, form.Username)
+				if dbErr != nil {
+					log.Errorf("unable to update authentication time for a given user: %s\n", dbErr.Error())
+					resp = struct {
+						tc.Alerts
+					}{tc.CreateAlerts(tc.ErrorLevel, "Unable to update authentication time for a given user")}
+				} else {
+					resp = struct {
+						tc.Alerts
+					}{tc.CreateAlerts(tc.SuccessLevel, "Successfully logged in.")}
+				}
+
 			} else {
 				resp = struct {
 					tc.Alerts
diff --git a/traffic_ops/traffic_ops_golang/user/current.go b/traffic_ops/traffic_ops_golang/user/current.go
index 54a43e9..d12abe0 100644
--- a/traffic_ops/traffic_ops_golang/user/current.go
+++ b/traffic_ops/traffic_ops_golang/user/current.go
@@ -105,15 +105,26 @@ func Current(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 	defer inf.Close()
+
 	currentUser, err := getUser(inf.Tx.Tx, inf.User.ID)
 	if err != nil {
 		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, nil, errors.New("getting current user: "+err.Error()))
 		return
 	}
-	api.WriteResp(w, r, currentUser)
+
+	version := inf.Version
+	if version == nil {
+		api.HandleErr(w, r, inf.Tx.Tx, http.StatusInternalServerError, fmt.Errorf("TOUsers.Read called with invalid API version"), nil)
+		return
+	}
+	if version.Major >= 4 {
+		api.WriteResp(w, r, currentUser)
+	} else {
+		api.WriteResp(w, r, currentUser.UserCurrent)
+	}
 }
 
-func getUser(tx *sql.Tx, id int) (tc.UserCurrent, error) {
+func getUser(tx *sql.Tx, id int) (tc.UserCurrentV40, error) {
 	q := `
 SELECT
 u.address_line1,
@@ -125,6 +136,7 @@ u.email,
 u.full_name,
 u.id,
 u.last_updated,
+u.last_authenticated,
 u.local_passwd,
 u.new_user,
 u.phone_number,
@@ -141,10 +153,10 @@ LEFT JOIN role as r ON r.id = u.role
 INNER JOIN tenant as t ON t.id = u.tenant_id
 WHERE u.id=$1
 `
-	u := tc.UserCurrent{}
+	u := tc.UserCurrentV40{}
 	localPassword := sql.NullString{}
-	if err := tx.QueryRow(q, id).Scan(&u.AddressLine1, &u.AddressLine2, &u.City, &u.Company, &u.Country, &u.Email, &u.FullName, &u.ID, &u.LastUpdated, &localPassword, &u.NewUser, &u.PhoneNumber, &u.PostalCode, &u.PublicSSHKey, &u.Role, &u.RoleName, &u.StateOrProvince, &u.Tenant, &u.TenantID, &u.UserName); err != nil {
-		return tc.UserCurrent{}, errors.New("querying current user: " + err.Error())
+	if err := tx.QueryRow(q, id).Scan(&u.AddressLine1, &u.AddressLine2, &u.City, &u.Company, &u.Country, &u.Email, &u.FullName, &u.ID, &u.LastUpdated, &u.LastAuthenticated, &localPassword, &u.NewUser, &u.PhoneNumber, &u.PostalCode, &u.PublicSSHKey, &u.Role, &u.RoleName, &u.StateOrProvince, &u.Tenant, &u.TenantID, &u.UserName); err != nil {
+		return tc.UserCurrentV40{}, errors.New("querying current user: " + err.Error())
 	}
 	u.LocalUser = util.BoolPtr(localPassword.Valid)
 	return u, nil
diff --git a/traffic_ops/traffic_ops_golang/user/user.go b/traffic_ops/traffic_ops_golang/user/user.go
index 5765159..e3fd5f4 100644
--- a/traffic_ops/traffic_ops_golang/user/user.go
+++ b/traffic_ops/traffic_ops_golang/user/user.go
@@ -229,7 +229,8 @@ func (this *TOUser) Read(h http.Header, useIMS bool) ([]interface{}, error, erro
 	}
 	type UserGet40 struct {
 		UserGet
-		ChangeLogCount *int `json:"changeLogCount" db:"change_log_count"`
+		ChangeLogCount    *int       `json:"changeLogCount" db:"change_log_count"`
+		LastAuthenticated *time.Time `json:"lastAuthenticated" db:"last_authenticated"`
 	}
 
 	user := &UserGet{}
@@ -431,6 +432,7 @@ func (user *TOUser) SelectQuery40() string {
 	u.tenant_id,
 	t.name as tenant,
 	u.last_updated,
+	u.last_authenticated,
 	(SELECT count(l.tm_user) FROM log as l WHERE l.tm_user = u.id) as change_log_count
 	FROM tm_user u
 	LEFT JOIN tenant t ON u.tenant_id = t.id
diff --git a/traffic_ops/v4-client/user.go b/traffic_ops/v4-client/user.go
index 229cd90..2c30d2d 100644
--- a/traffic_ops/v4-client/user.go
+++ b/traffic_ops/v4-client/user.go
@@ -24,6 +24,9 @@ import (
 	"github.com/apache/trafficcontrol/traffic_ops/toclientlib"
 )
 
+// UserCurrentResponseV4 is an alias to avoid client breaking changes. In-case of a minor or major version change, we replace the below alias with a new structure.
+type UserCurrentResponseV4 = tc.UserCurrentResponseV40
+
 // GetUsers retrieves all (Tenant-accessible) Users stored in Traffic Ops.
 func (to *Session) GetUsers(opts RequestOptions) (tc.UsersResponseV40, toclientlib.ReqInf, error) {
 	data := tc.UsersResponseV40{}
@@ -33,9 +36,9 @@ func (to *Session) GetUsers(opts RequestOptions) (tc.UsersResponseV40, toclientl
 }
 
 // GetUserCurrent retrieves the currently authenticated User.
-func (to *Session) GetUserCurrent(opts RequestOptions) (tc.UserCurrentResponse, toclientlib.ReqInf, error) {
+func (to *Session) GetUserCurrent(opts RequestOptions) (UserCurrentResponseV4, toclientlib.ReqInf, error) {
 	route := `/user/current`
-	resp := tc.UserCurrentResponse{}
+	resp := UserCurrentResponseV4{}
 	reqInf, err := to.get(route, opts, &resp)
 	return resp, reqInf, err
 }
diff --git a/traffic_portal/app/src/common/modules/table/capabilityUsers/TableCapabilityUsersController.js b/traffic_portal/app/src/common/modules/table/capabilityUsers/TableCapabilityUsersController.js
index 52b4b84..1fd5653 100644
--- a/traffic_portal/app/src/common/modules/table/capabilityUsers/TableCapabilityUsersController.js
+++ b/traffic_portal/app/src/common/modules/table/capabilityUsers/TableCapabilityUsersController.js
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-var TableCapabilityUsersController = function(capability, capUsers, $controller, $scope, $state, locationUtils) {
+var TableCapabilityUsersController = function(capability, capUsers, $controller, $scope, $state, dateUtils, locationUtils) {
 
 	// extends the TableUsersController to inherit common methods
 	angular.extend(this, $controller('TableUsersController', { users: capUsers, $scope: $scope }));
@@ -26,6 +26,8 @@ var TableCapabilityUsersController = function(capability, capUsers, $controller,
 
 	$scope.capability = capability[0];
 
+	$scope.relativeLoginTime = dateUtils.relativeLoginTime;
+
 	$scope.editUser = function(id) {
 		locationUtils.navigateToPath('/users/' + id);
 	};
@@ -48,7 +50,7 @@ var TableCapabilityUsersController = function(capability, capUsers, $controller,
 			"iDisplayLength": 25,
 			"aaSorting": [],
 			"columns": $scope.columns,
-			"initComplete": function(settings, json) {
+			"initComplete": function() {
 				try {
 					// need to create the show/hide column checkboxes and bind to the current visibility
 					$scope.columns = JSON.parse(localStorage.getItem('DataTables_capUsersTable_/')).columns;
@@ -61,5 +63,5 @@ var TableCapabilityUsersController = function(capability, capUsers, $controller,
 
 };
 
-TableCapabilityUsersController.$inject = ['capability', 'capUsers', '$controller', '$scope', '$state', 'locationUtils'];
+TableCapabilityUsersController.$inject = ['capability', 'capUsers', '$controller', '$scope', '$state', 'dateUtils', 'locationUtils'];
 module.exports = TableCapabilityUsersController;
diff --git a/traffic_portal/app/src/common/modules/table/capabilityUsers/table.capabilityUsers.tpl.html b/traffic_portal/app/src/common/modules/table/capabilityUsers/table.capabilityUsers.tpl.html
index dea80c5..b453f2b 100644
--- a/traffic_portal/app/src/common/modules/table/capabilityUsers/table.capabilityUsers.tpl.html
+++ b/traffic_portal/app/src/common/modules/table/capabilityUsers/table.capabilityUsers.tpl.html
@@ -53,6 +53,7 @@ under the License.
                 <th>Tenant</th>
                 <th>Role</th>
                 <th>Registration Sent</th>
+                <th>Last Authenticated</th>
                 <th>Change Log Count</th>
             </tr>
             </thead>
@@ -64,6 +65,7 @@ under the License.
                 <td data-search="^{{::u.tenant}}$">{{::u.tenant}}</td>
                 <td data-search="^{{::u.rolename}}$">{{::u.rolename}}</td>
                 <td data-search="^{{::u.registrationSent}}$">{{::u.registrationSent}}</td>
+                <td data-search="^{{::relativeLoginTime(u.lastAuthenticated)}}$">{{::relativeLoginTime(u.lastAuthenticated)}}</td>
                 <td data-search="^{{::u.changeLogCount}}$">{{::u.changeLogCount}}</td>
             </tr>
             </tbody>
diff --git a/traffic_portal/app/src/common/modules/table/roleUsers/TableRoleUsersController.js b/traffic_portal/app/src/common/modules/table/roleUsers/TableRoleUsersController.js
index c6fd168..e741f35 100644
--- a/traffic_portal/app/src/common/modules/table/roleUsers/TableRoleUsersController.js
+++ b/traffic_portal/app/src/common/modules/table/roleUsers/TableRoleUsersController.js
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-var TableRoleUsersController = function(roles, roleUsers, $controller, $scope, $state, locationUtils) {
+var TableRoleUsersController = function(roles, roleUsers, $controller, $scope, $state, dateUtils, locationUtils) {
 
 	// extends the TableUsersController to inherit common methods
 	angular.extend(this, $controller('TableUsersController', { users: roleUsers, $scope: $scope }));
@@ -26,6 +26,8 @@ var TableRoleUsersController = function(roles, roleUsers, $controller, $scope, $
 
 	$scope.role = roles[0];
 
+	$scope.relativeLoginTime = dateUtils.relativeLoginTime;
+
 	$scope.editUser = function(id) {
 		locationUtils.navigateToPath('/users/' + id);
 	};
@@ -48,7 +50,7 @@ var TableRoleUsersController = function(roles, roleUsers, $controller, $scope, $
 			"iDisplayLength": 25,
 			"aaSorting": [],
 			"columns": $scope.columns,
-			"initComplete": function(settings, json) {
+			"initComplete": function() {
 				try {
 					// need to create the show/hide column checkboxes and bind to the current visibility
 					$scope.columns = JSON.parse(localStorage.getItem('DataTables_roleUsersTable_/')).columns;
@@ -61,5 +63,5 @@ var TableRoleUsersController = function(roles, roleUsers, $controller, $scope, $
 
 };
 
-TableRoleUsersController.$inject = ['roles', 'roleUsers', '$controller', '$scope', '$state', 'locationUtils'];
+TableRoleUsersController.$inject = ['roles', 'roleUsers', '$controller', '$scope', '$state', 'dateUtils', 'locationUtils'];
 module.exports = TableRoleUsersController;
diff --git a/traffic_portal/app/src/common/modules/table/roleUsers/table.roleUsers.tpl.html b/traffic_portal/app/src/common/modules/table/roleUsers/table.roleUsers.tpl.html
index df0eb84..39adb98 100644
--- a/traffic_portal/app/src/common/modules/table/roleUsers/table.roleUsers.tpl.html
+++ b/traffic_portal/app/src/common/modules/table/roleUsers/table.roleUsers.tpl.html
@@ -53,6 +53,7 @@ under the License.
                 <th>Tenant</th>
                 <th>Role</th>
                 <th>Registration Sent</th>
+                <th>Last Authenticated</th>
                 <th>Change Log Count</th>
             </tr>
             </thead>
@@ -64,6 +65,7 @@ under the License.
                 <td data-search="^{{::u.tenant}}$">{{::u.tenant}}</td>
                 <td data-search="^{{::u.rolename}}$">{{::u.rolename}}</td>
                 <td data-search="^{{::u.registrationSent}}$">{{::u.registrationSent}}</td>
+                <td data-search="^{{::relativeLoginTime(u.lastAuthenticated)}}$">{{::relativeLoginTime(u.lastAuthenticated)}}</td>
                 <td data-search="^{{::u.changeLogCount}}$">{{::u.changeLogCount}}</td>
             </tr>
             </tbody>
diff --git a/traffic_portal/app/src/common/modules/table/tenantUsers/TableTenantUsersController.js b/traffic_portal/app/src/common/modules/table/tenantUsers/TableTenantUsersController.js
index f83ca8c..8f31e37 100644
--- a/traffic_portal/app/src/common/modules/table/tenantUsers/TableTenantUsersController.js
+++ b/traffic_portal/app/src/common/modules/table/tenantUsers/TableTenantUsersController.js
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-var TableTenantUsersController = function(tenant, tenantUsers, $controller, $scope, $state, locationUtils) {
+var TableTenantUsersController = function(tenant, tenantUsers, $controller, $scope, $state, dateUtils, locationUtils) {
 
 	// extends the TableUsersController to inherit common methods
 	angular.extend(this, $controller('TableUsersController', { users: tenantUsers, $scope: $scope }));
@@ -26,6 +26,8 @@ var TableTenantUsersController = function(tenant, tenantUsers, $controller, $sco
 
 	$scope.tenant = tenant;
 
+	$scope.relativeLoginTime = dateUtils.relativeLoginTime;
+
 	$scope.editUser = function(id) {
 		locationUtils.navigateToPath('/users/' + id);
 	};
@@ -48,7 +50,7 @@ var TableTenantUsersController = function(tenant, tenantUsers, $controller, $sco
 			"iDisplayLength": 25,
 			"aaSorting": [],
 			"columns": $scope.columns,
-			"initComplete": function(settings, json) {
+			"initComplete": function() {
 				try {
 					// need to create the show/hide column checkboxes and bind to the current visibility
 					$scope.columns = JSON.parse(localStorage.getItem('DataTables_tenantUsersTable_/')).columns;
@@ -61,5 +63,5 @@ var TableTenantUsersController = function(tenant, tenantUsers, $controller, $sco
 
 };
 
-TableTenantUsersController.$inject = ['tenant', 'tenantUsers', '$controller', '$scope', '$state', 'locationUtils'];
+TableTenantUsersController.$inject = ['tenant', 'tenantUsers', '$controller', '$scope', '$state', 'dateUtils', 'locationUtils'];
 module.exports = TableTenantUsersController;
diff --git a/traffic_portal/app/src/common/modules/table/tenantUsers/table.tenantUsers.tpl.html b/traffic_portal/app/src/common/modules/table/tenantUsers/table.tenantUsers.tpl.html
index 4e83725..168cfc5 100644
--- a/traffic_portal/app/src/common/modules/table/tenantUsers/table.tenantUsers.tpl.html
+++ b/traffic_portal/app/src/common/modules/table/tenantUsers/table.tenantUsers.tpl.html
@@ -53,6 +53,7 @@ under the License.
                 <th>Tenant</th>
                 <th>Role</th>
                 <th>Registration Sent</th>
+                <th>Last Authenticated</th>
                 <th>Change Log Count</th>
             </tr>
             </thead>
@@ -64,6 +65,7 @@ under the License.
                 <td data-search="^{{::u.tenant}}$">{{::u.tenant}}</td>
                 <td data-search="^{{::u.rolename}}$">{{::u.rolename}}</td>
                 <td data-search="^{{::u.registrationSent}}$">{{::u.registrationSent}}</td>
+                <td data-search="^{{::relativeLoginTime(u.lastAuthenticated)}}$">{{::relativeLoginTime(u.lastAuthenticated)}}</td>
                 <td data-search="^{{::u.changeLogCount}}$">{{::u.changeLogCount}}</td>
             </tr>
             </tbody>
diff --git a/traffic_portal/app/src/common/modules/table/users/TableUsersController.js b/traffic_portal/app/src/common/modules/table/users/TableUsersController.js
index c32fbc5..8ea177b 100644
--- a/traffic_portal/app/src/common/modules/table/users/TableUsersController.js
+++ b/traffic_portal/app/src/common/modules/table/users/TableUsersController.js
@@ -17,12 +17,14 @@
  * under the License.
  */
 
-var TableUsersController = function(users, $scope, $state, locationUtils) {
+var TableUsersController = function(users, $scope, $state, dateUtils, locationUtils) {
 
     let usersTable;
 
     $scope.users = users;
 
+    $scope.relativeLoginTime = dateUtils.relativeLoginTime;
+
     $scope.columns = [
         { "name": "Full Name", "visible": true, "searchable": true },
         { "name": "Username", "visible": true, "searchable": true },
@@ -30,7 +32,8 @@ var TableUsersController = function(users, $scope, $state, locationUtils) {
         { "name": "Tenant", "visible": true, "searchable": true },
         { "name": "Role", "visible": true, "searchable": true },
         { "name": "Registration Sent", "visible": false, "searchable": true },
-        { "name": "Change Log Count", "visible": false, "searchable": false }
+        { "name": "Last Authenticated", "visible": false, "searchable": false },
+        { "name": "Change Log Count", "visible": false, "searchable": false },
     ];
 
     $scope.editUser = function(id) {
@@ -61,7 +64,7 @@ var TableUsersController = function(users, $scope, $state, locationUtils) {
             "iDisplayLength": 25,
             "aaSorting": [],
             "columns": $scope.columns,
-            "initComplete": function(settings, json) {
+            "initComplete": function() {
                 try {
                     // need to create the show/hide column checkboxes and bind to the current visibility
                     $scope.columns = JSON.parse(localStorage.getItem('DataTables_usersTable_/')).columns;
@@ -74,5 +77,5 @@ var TableUsersController = function(users, $scope, $state, locationUtils) {
 
 };
 
-TableUsersController.$inject = ['users', '$scope', '$state', 'locationUtils'];
+TableUsersController.$inject = ['users', '$scope', '$state', 'dateUtils', 'locationUtils'];
 module.exports = TableUsersController;
diff --git a/traffic_portal/app/src/common/modules/table/users/table.users.tpl.html b/traffic_portal/app/src/common/modules/table/users/table.users.tpl.html
index c933abe..48bf88e 100644
--- a/traffic_portal/app/src/common/modules/table/users/table.users.tpl.html
+++ b/traffic_portal/app/src/common/modules/table/users/table.users.tpl.html
@@ -53,6 +53,7 @@ under the License.
                 <th>Tenant</th>
                 <th>Role</th>
                 <th>Registration Sent</th>
+                <th>Last Authenticated</th>
                 <th>Change Log Count</th>
             </tr>
             </thead>
@@ -64,6 +65,7 @@ under the License.
                 <td data-search="^{{::u.tenant}}$">{{::u.tenant}}</td>
                 <td data-search="^{{::u.rolename}}$">{{::u.rolename}}</td>
                 <td data-search="^{{::u.registrationSent}}$">{{::u.registrationSent}}</td>
+                <td data-search="^{{::relativeLoginTime(u.lastAuthenticated)}}$">{{::relativeLoginTime(u.lastAuthenticated)}}</td>
                 <td data-search="^{{::u.changeLogCount}}$">{{::u.changeLogCount}}</td>
             </tr>
             </tbody>
@@ -71,7 +73,3 @@ under the License.
     </div>
 </div>
 
-
-
-
-
diff --git a/traffic_portal/app/src/common/service/utils/DateUtils.js b/traffic_portal/app/src/common/service/utils/DateUtils.js
index 40098d7..433e58d 100644
--- a/traffic_portal/app/src/common/service/utils/DateUtils.js
+++ b/traffic_portal/app/src/common/service/utils/DateUtils.js
@@ -38,7 +38,7 @@ var DateUtils = function() {
 			var dF = this.dateFormat;
 
 			// You can't provide utc if you skip other args (use the "UTC:" mask prefix)
-			if (arguments.length == 1 && Object.prototype.toString.call(date) == "[object String]" && !/\d/.test(date)) {
+			if (arguments.length === 1 && Object.prototype.toString.call(date) === "[object String]" && !/\d/.test(date)) {
 				mask = date;
 				date = undefined;
 			}
@@ -50,7 +50,7 @@ var DateUtils = function() {
 			mask = String(dF.masks[mask] || mask || dF.masks["default"]);
 
 			// Allow setting the utc argument via the mask
-			if (mask.slice(0, 4) == "UTC:") {
+			if (mask.slice(0, 4) === "UTC:") {
 				mask = mask.slice(4);
 				utc = true;
 			}
@@ -92,7 +92,7 @@ var DateUtils = function() {
 					TT:   H < 12 ? "AM" : "PM",
 					Z:    utc ? "UTC" : (String(date).match(timezone) || [""]).pop().replace(timezoneClip, ""),
 					o:    (o > 0 ? "-" : "+") + pad(Math.floor(Math.abs(o) / 60) * 100 + Math.abs(o) % 60, 4),
-					S:    ["th", "st", "nd", "rd"][d % 10 > 3 ? 0 : (d % 100 - d % 10 != 10) * d % 10]
+					S:    ["th", "st", "nd", "rd"][d % 10 > 3 ? 0 : (d % 100 - d % 10 !== 10) * d % 10]
 				};
 
 			return mask.replace(token, function ($0) {
@@ -142,7 +142,21 @@ var DateUtils = function() {
 	 */
 	this.getRelativeTime = function(date) {
 		return moment(date).fromNow();
-	}
+	};
+
+	/**
+	 * Converts a date into a string that tells how much time is between the current time and the given date and whether
+	 * the user has logged into the system.
+	 * @param {Date | string} date Either a Date object or a string that can be parsed by momentjs.
+	 * @returns {string} A human readable description of how much time is between now and `date`.
+	 */
+	this.relativeLoginTime = function(date) {
+		if (date) {
+			return moment(date).fromNow();
+		} else {
+			return "Never logged in";
+		}
+	};
 
 };