Add last login time in User when logging in (#20390)

* Add last login time

* Add change to entitySpecificUpdate

* remove last login time from createOrUpdate

* moved UpdateLastLogin to user repository

* Added last login Time on login  (#20419)

* updated last login time

* removed condition

* added unit tests

---------

Co-authored-by: Dhruv Parmar <83108871+dhruvjsx@users.noreply.github.com>
Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com>
This commit is contained in:
Mohit Yadav 2025-03-31 10:25:03 +05:30 committed by GitHub
parent 321b8f7f65
commit 4aa8a0c0a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 106 additions and 15 deletions

View File

@ -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);
}
}

View File

@ -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> {
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<User> {
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);

View File

@ -600,7 +600,10 @@ public class UserResource extends EntityResource<User, UserRepository> {
} 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(

View File

@ -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",

View File

@ -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());
}

View File

@ -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());
}

View File

@ -136,6 +136,7 @@ public class SamlAssertionConsumerServlet extends HttpServlet {
jwtAuthMechanism.getJWTToken(),
nameId,
username);
Entity.getUserRepository().updateUserLastLoginTime(user, System.currentTimeMillis());
resp.sendRedirect(url);
}
}

View File

@ -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,

View File

@ -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.

View File

@ -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,
},
]);
});
});

View File

@ -90,6 +90,7 @@ export const checkIfUpdateRequired = async (
newUser: OidcUser
): Promise<User> => {
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;