diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index f02df987f7e..7224929669a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -72,6 +72,7 @@ import org.openmetadata.service.jdbi3.SystemRepository; import org.openmetadata.service.jdbi3.TokenRepository; import org.openmetadata.service.jdbi3.TypeRepository; import org.openmetadata.service.jdbi3.UsageRepository; +import org.openmetadata.service.jdbi3.UserRepository; import org.openmetadata.service.jobs.JobDAO; import org.openmetadata.service.resources.feeds.MessageParser.EntityLink; import org.openmetadata.service.search.SearchRepository; @@ -740,4 +741,8 @@ public final class Entity { return allServices; } + + public static UserRepository getUserRepository() { + return (UserRepository) Entity.getEntityRepository(Entity.USER); + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java index 0028c8b2f1b..f2079973b84 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/UserRepository.java @@ -25,6 +25,7 @@ import static org.openmetadata.service.Entity.FIELD_DOMAINS; import static org.openmetadata.service.Entity.ROLE; import static org.openmetadata.service.Entity.TEAM; import static org.openmetadata.service.Entity.USER; +import static org.openmetadata.service.util.EntityUtil.objectMatch; import java.io.IOException; import java.util.ArrayList; @@ -37,6 +38,7 @@ import java.util.TreeSet; import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; +import javax.json.JsonPatch; import javax.ws.rs.core.SecurityContext; import javax.ws.rs.core.UriInfo; import lombok.extern.slf4j.Slf4j; @@ -221,6 +223,14 @@ public class UserRepository extends EntityRepository { user.withRoles(roles).withTeams(teams); } + public void updateUserLastLoginTime(User orginalUser, long lastLoginTime) { + User updatedUser = JsonUtils.deepCopy(orginalUser, User.class); + JsonPatch patch = + JsonUtils.getJsonPatch(orginalUser, updatedUser.withLastLoginTime(lastLoginTime)); + UserRepository userRepository = (UserRepository) Entity.getEntityRepository(Entity.USER); + userRepository.patch(null, orginalUser.getId(), orginalUser.getUpdatedBy(), patch); + } + @Override public void storeRelationships(User user) { assignRoles(user, user.getRoles()); @@ -632,6 +642,13 @@ public class UserRepository extends EntityRepository { public void entitySpecificUpdate(boolean consolidatingChanges) { // LowerCase Email updated.setEmail(original.getEmail().toLowerCase()); + recordChange( + "lastLoginTime", + original.getLastLoginTime(), + updated.getLastLoginTime(), + false, + objectMatch, + true); // Updates updateRoles(original, updated); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java index 0558b57c55e..e447ec15a14 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/teams/UserResource.java @@ -600,7 +600,10 @@ public class UserResource extends EntityResource { } catch (EntityNotFoundException ex) { if (isSelfSignUpEnabled) { if (securityContext.getUserPrincipal().getName().equals(user.getName())) { - User created = addHref(uriInfo, repository.create(uriInfo, user)); + User created = + addHref( + uriInfo, + repository.create(uriInfo, user.withLastLoginTime(System.currentTimeMillis()))); createdUserRes = Response.created(created.getHref()).entity(created).build(); } else { throw new CustomExceptionMessage( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java index 9d5f8f7389f..b5809240381 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/AuthenticationCodeFlowHandler.java @@ -663,6 +663,12 @@ public class AuthenticationCodeFlowHandler { String redirectUri = (String) request.getSession().getAttribute(SESSION_REDIRECT_URI); + String storedUserStr = + Entity.getCollectionDAO().userDAO().findUserByNameAndEmail(userName, email); + if (storedUserStr != null) { + User user = JsonUtils.readValue(storedUserStr, User.class); + Entity.getUserRepository().updateUserLastLoginTime(user, System.currentTimeMillis()); + } String url = String.format( "%s?id_token=%s&email=%s&name=%s", diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java index 6fc4d08c7ae..90f3f823256 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/BasicAuthenticator.java @@ -469,6 +469,7 @@ public class BasicAuthenticator implements AuthenticatorHandler { checkIfLoginBlocked(email); User storedUser = lookUserInProvider(email, loginRequest.getPassword()); validatePassword(email, loginRequest.getPassword(), storedUser); + Entity.getUserRepository().updateUserLastLoginTime(storedUser, System.currentTimeMillis()); return getJwtResponse(storedUser, SecurityUtil.getLoginConfiguration().getJwtTokenExpiryTime()); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java index d608434d7b2..e37371e8467 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/auth/LdapAuthenticator.java @@ -143,6 +143,7 @@ public class LdapAuthenticator implements AuthenticatorHandler { String email = loginRequest.getEmail(); checkIfLoginBlocked(email); User omUser = lookUserInProvider(email, loginRequest.getPassword()); + Entity.getUserRepository().updateUserLastLoginTime(omUser, System.currentTimeMillis()); return getJwtResponse(omUser, SecurityUtil.getLoginConfiguration().getJwtTokenExpiryTime()); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java index 516ba6d54e8..fb663da72bc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/security/saml/SamlAssertionConsumerServlet.java @@ -136,6 +136,7 @@ public class SamlAssertionConsumerServlet extends HttpServlet { jwtAuthMechanism.getJWTToken(), nameId, username); + Entity.getUserRepository().updateUserLastLoginTime(user, System.currentTimeMillis()); resp.sendRedirect(url); } } diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/teams/user.json b/openmetadata-spec/src/main/resources/json/schema/entity/teams/user.json index a94f43e44e5..4cc48a57264 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/teams/user.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/teams/user.json @@ -143,6 +143,10 @@ "domains" : { "description": "Domain the User belongs to. This is inherited by the team the user belongs to.", "$ref": "../../type/entityReferenceList.json" + }, + "lastLoginTime": { + "description": "Last time the user logged in.", + "$ref": "../../type/basic.json#/definitions/timestamp" } }, "additionalProperties": false, diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts index 393877bc16a..4aa1bda536c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/teams/user.ts @@ -82,6 +82,10 @@ export interface User { * If the User has verified the mail */ isEmailVerified?: boolean; + /** + * Last time the user logged in. + */ + lastLoginTime?: number; /** * A unique name of the user, typically the user ID from an identity provider. Example - uid * from LDAP. diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.test.ts index bf2e5dab32f..542b2b8c84c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.test.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.test.ts @@ -12,6 +12,7 @@ */ import { OidcUser } from '../components/Auth/AuthProviders/AuthProvider.interface'; import { User } from '../generated/entity/teams/user'; +import * as userAPI from '../rest/userAPI'; import { checkIfUpdateRequired, getUserWithImage } from './UserDataUtils'; describe('getUserWithImage', () => { @@ -91,6 +92,16 @@ describe('getUserWithImage', () => { }); describe('checkIfUpdateRequired', () => { + const mockTimestamp = 1642512000000; + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => mockTimestamp); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return the updated user details if update is required', async () => { const existingUserDetails: User = { email: 'a@a.com', @@ -149,4 +160,41 @@ describe('checkIfUpdateRequired', () => { expect(updatedUserDetails).toEqual(existingUserDetails); }); + + it('should call updateUserDetail with correct payload', async () => { + // Import the module containing the function + + const existingUserDetails: User = { + email: 'a@a.com', + id: '1', + name: 'user', + isBot: false, + }; + // Spy on the function within the module + const mockUpdateUserDetail = jest + .spyOn(userAPI, 'updateUserDetail') + .mockResolvedValue(existingUserDetails); + + const newUser: OidcUser = { + id_token: 'idToken', + scope: 'scope', + profile: { + email: 'a@a.com', + name: 'user', + picture: '', + preferred_username: 'preferred_username', + sub: 'sub', + }, + }; + + await checkIfUpdateRequired(existingUserDetails, newUser); + + expect(mockUpdateUserDetail).toHaveBeenCalledWith(existingUserDetails.id, [ + { + op: 'add', + path: '/lastLoginTime', + value: mockTimestamp, + }, + ]); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.ts index dc80270c6a2..fc60015e15f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/UserDataUtils.ts @@ -90,6 +90,7 @@ export const checkIfUpdateRequired = async ( newUser: OidcUser ): Promise => { const updatedUserData = getUserDataFromOidc(existingUserDetails, newUser); + let finalData = { ...existingUserDetails, lastLoginTime: Date.now() }; if (existingUserDetails.email !== updatedUserData.email) { return existingUserDetails; @@ -99,28 +100,28 @@ export const checkIfUpdateRequired = async ( updatedUserData.profile?.images && !matchUserDetails(existingUserDetails, updatedUserData, ['profile']) ) { - const finalData = { - ...existingUserDetails, + finalData = { + ...finalData, // We want to override any profile information that is coming from the OIDC provider profile: { ...existingUserDetails.profile, ...updatedUserData.profile, }, }; - const jsonPatch = compare(existingUserDetails, finalData); + } + const jsonPatch = compare(existingUserDetails, finalData); - try { - const res = await updateUserDetail(existingUserDetails.id, jsonPatch); + try { + const res = await updateUserDetail(existingUserDetails.id, jsonPatch); - return res; - } catch (error) { - showErrorToast( - error as AxiosError, - i18n.t('server.entity-updating-error', { - entity: i18n.t('label.admin-profile'), - }) - ); - } + return res; + } catch (error) { + showErrorToast( + error as AxiosError, + i18n.t('server.entity-updating-error', { + entity: i18n.t('label.admin-profile'), + }) + ); } return existingUserDetails;