diff --git a/docs/README.md b/docs/README.md index 18ec05d..41bc96e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,8 +24,12 @@ It is important to highlight that this plugin is provided on an 'as-is' basis, w * the groups received in the related header will be stored in a dedicated database table and become available for the 'external role mapping' functionality * ⚠️ note: [currently it is necessary](https://github.com/tumbl3w33d/nexus-oauth2-proxy-plugin/issues/26) to use this mapping mechanism because assigning Nexus' default roles to users created via plugin has no effect * automatic expiry of API tokens - * there is a configurable task that lets API tokens expire, so another login by the user is necessary to renew it - * as long as the user keeps showing up regularly, their token will not expire + * there is a configurable task that lets API tokens expire, so another login and manual renewal by the user is necessary + * by default, the token will expire after 30 days of inactivity. As long as the user keeps showing up regularly, their token will not expire + * The expiration can be configured as a regular nexus task. You can: + * adjust inactivity period that leads to token invalidation + * enable and set max token age, meaning the token automatically expires after a certain time, regardless of activity + * enable mail notifications on token expiry (requires having a mail server configured in Nexus to work) * backchannel logout in IDP via oauth2 proxy (if supported) when the logout is performed in Nexus * make sure to enable the OAuth2 Proxy: Logout capability in Nexus to make this work @@ -168,6 +172,14 @@ skip_provider_button = true **Note**: Depending on the amount of data the OAuth2 Proxy receives from your IDP (especially list of groups) you might want to look into [changing its session storage to redis/valkey](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/#redis-storage). The proxy will warn you about data exceeding limits which results in multiple cookies being set for the proxy session. +## Optional: Bearer token authentication and token rotation + +If for some reason you need to use a Bearer token for machine-to-machine communcation or in general accessing Nexus programmatically e.g. because corporate guidelines prevent you from using Basic Auth using the api token, it is possible to set this up: + +Add 'skip_jwt_bearer_tokens = true' to your OAuth2 Proxy configuration. This flag makes OAuth2 Proxy optionally accept Bearer tokens instead of performing the auth flow itself as long as the Bearer token is valid and the audience matches the configured client id. For details, check the [official documentation](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/). OAuth2 Proxy will then populate the x-forwarded headers based on information from this token, so for Nexus the login mechanism is still transparent. + +Leveraging this way of authenticating it is possible to create an automatic token rotation even if the API token is invalidated by obtaining the Bearer token via some kind of OIDC login and then using it to perform an authenticated REST call against https://your_nexus_host/service/rest/oauth2-proxy/user/reset-token to obtain a new API token. Keep in mind that this REST call immediately invalidates the old token! Also make sure your reverse proxy is configured to route this URL to Nexus via OAuth2 Proxy (if you use the above example configs, this should automatically be the case) + ## Troubleshooting If you encounter authentication issues, you can activate logging for the plugin classes by creating a logger in the Nexus administration section (`Support -> Logging -> Create Logger`), e.g. for the top level package `com.github.tumbl3w33d`. diff --git a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTask.java b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTask.java index 410cd06..ce7ba6e 100644 --- a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTask.java +++ b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTask.java @@ -5,83 +5,122 @@ import java.sql.Timestamp; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.List; +import java.util.HashSet; +import java.util.Map; import java.util.Set; import javax.inject.Inject; import javax.inject.Named; +import org.apache.commons.mail.EmailException; +import org.apache.commons.mail.SimpleEmail; +import org.sonatype.nexus.common.app.BaseUrlHolder; +import org.sonatype.nexus.email.EmailManager; import org.sonatype.nexus.logging.task.TaskLogging; import org.sonatype.nexus.scheduling.Cancelable; import org.sonatype.nexus.scheduling.TaskSupport; -import org.sonatype.nexus.security.user.UserManager; +import org.sonatype.nexus.security.SecuritySystem; +import org.sonatype.nexus.security.user.User; import org.sonatype.nexus.security.user.UserNotFoundException; import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordStore; +import com.github.tumbl3w33d.h2.OAuth2ProxyTokenInfoStore; import com.github.tumbl3w33d.users.OAuth2ProxyUserManager; import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; +import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo; @Named @TaskLogging(NEXUS_LOG_ONLY) public class OAuth2ProxyApiTokenInvalidateTask extends TaskSupport implements Cancelable { private final OAuth2ProxyLoginRecordStore loginRecordStore; - + private final OAuth2ProxyTokenInfoStore tokenInfoStore; private final OAuth2ProxyUserManager userManager; + private final SecuritySystem securitySystem; + private final EmailManager mailManager; @Inject public OAuth2ProxyApiTokenInvalidateTask(@Named OAuth2ProxyLoginRecordStore loginRecordStore, - final List userManagers, final OAuth2ProxyUserManager userManager) { - + @Named OAuth2ProxyTokenInfoStore tokenInfoStore, @Named OAuth2ProxyUserManager userManager, + SecuritySystem securitySystem, EmailManager mailManager) { this.loginRecordStore = loginRecordStore; + this.tokenInfoStore = tokenInfoStore; this.userManager = userManager; - } - - private void resetApiToken(String userId) { - try { - userManager.changePassword(userId, OAuth2ProxyRealm.generateSecureRandomString(32)); - log.debug("API token reset for user {} succeeded", userId); - } catch (UserNotFoundException e) { - log.error("Unable to reset API token of user {} - {}", userId, e); - } + this.securitySystem = securitySystem; + this.mailManager = mailManager; } @Override protected Void execute() throws Exception { + Map loginRecords = loginRecordStore.getAllLoginRecords(); + Map tokenInfos = tokenInfoStore.getAllTokenInfos(); - Set loginRecords = loginRecordStore.getAllLoginRecords(); - - if (loginRecords.isEmpty()) { - log.debug("No login records found, nothing to do"); + if (loginRecords.isEmpty() && tokenInfos.isEmpty()) { + log.debug("No records found, nothing to do"); return null; } - for (OAuth2ProxyLoginRecord loginRecord : loginRecords) { - String userId = loginRecord.getId(); - Timestamp lastLoginDate = loginRecord.getLastLogin(); - - Instant lastLoginInstant = lastLoginDate.toInstant(); - Instant nowInstant = Instant.now(); + int configuredIdleExpiration = getConfiguration().getInteger( + OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_IDLE_EXPIRY, + OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_IDLE_EXPIRY_DEFAULT); + + int configuredMaxTokenAge = getConfiguration().getInteger( + OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_AGE, + OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_AGE_DEFAULT); + + boolean notify = getConfiguration().getBoolean(OAuth2ProxyApiTokenInvalidateTaskDescriptor.NOTIFY, + OAuth2ProxyApiTokenInvalidateTaskDescriptor.NOTIFY_DEFAULT); + + Set userIds = new HashSet<>(loginRecords.size()); + userIds.addAll(loginRecords.keySet()); + userIds.addAll(tokenInfos.keySet()); + for (String userId : userIds) { + if ("admin".equals(userId)) { + // never reset the admin "token" as it would overwrite the password, possibly locking people out of nexus + // when the task would run before OIDC setup is completed + continue; + } - log.debug("Last known login for {} was {}", userId, - OAuth2ProxyRealm.formatDateString(lastLoginDate)); + if (isUserIdleTimeExpired(userId, loginRecords.get(userId), configuredIdleExpiration) + || isTokenLifespanExpired(userId, tokenInfos.get(userId), configuredMaxTokenAge)) { + resetApiToken(userId, notify); + log.info("API token of user {} has been reset", userId); + } - long timePassed = ChronoUnit.DAYS.between(lastLoginInstant, nowInstant); + } + return null; + } - int configuredDuration = getConfiguration() - .getInteger(OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_EXPIRY, 1); + private boolean isUserIdleTimeExpired(String userId, OAuth2ProxyLoginRecord loginRecord, int configuredIdleTime) { + if (configuredIdleTime <= 0) { + return false; + } - log.debug("Time passed since login: {} - configured maximum: {}", timePassed, - configuredDuration); + Timestamp lastLoginDate = loginRecord.getLastLogin(); + log.debug("Last known login for {} was {}", userId, OAuth2ProxyRealm.formatDateString(lastLoginDate)); + long timePassed = ChronoUnit.DAYS.between(lastLoginDate.toInstant(), Instant.now()); + log.debug("Time passed since login: {} - configured maximum: {}", timePassed, configuredIdleTime); + if (timePassed >= configuredIdleTime) { + log.debug("Idle time expired for {}", userId); + return true; + } + return false; + } - if (timePassed >= configuredDuration) { - resetApiToken(userId); - log.info("Reset api token of user {} because they did not show up for a while", - userId); - } + private boolean isTokenLifespanExpired(String userId, OAuth2ProxyTokenInfo tokenInfo, int configuredMaxTokenAge) { + if (configuredMaxTokenAge <= 0) { + return false; } - return null; + Timestamp tokenCreationDate = tokenInfo.getTokenCreation(); + log.debug("API token for {} was created at {}", userId, OAuth2ProxyRealm.formatDateString(tokenCreationDate)); + long timePassed = ChronoUnit.DAYS.between(tokenCreationDate.toInstant(), Instant.now()); + log.debug("Time passed since token creation: {} - configured maximum: {}", timePassed, configuredMaxTokenAge); + if (timePassed >= configuredMaxTokenAge) { + log.debug("Token lifespan expired for user {}", userId); + return true; + } + return false; } @Override @@ -89,4 +128,41 @@ public String getMessage() { return "Invalidate OAuth2 Proxy API tokens of users who did not show up for a while"; } + private void resetApiToken(String userId, boolean notify) { + try { + securitySystem.changePassword(userId, OAuth2ProxyRealm.generateSecureRandomString(32)); + log.debug("API token reset for user {} succeeded", userId); + if (notify) { + sendMail(userId); + } + } catch (UserNotFoundException e) { + log.error("Unable to reset API token of user {}", userId); + log.debug("Unable to reset API token of user {}", userId, e); + } + } + + private void sendMail(String userId) throws UserNotFoundException { + if (mailManager.getConfiguration().isEnabled()) { + User user = userManager.getUser(userId); + String to = user.getEmailAddress(); + try { + SimpleEmail mail = new SimpleEmail(); + mail.addTo(to); + if (BaseUrlHolder.isSet()) { + mail.setMsg("Your OAuth2 Proxy API Token on " + BaseUrlHolder.get() + + " has been invalidated because of inactivity or expired token lifespan"); + } else { + mail.setMsg( + "Your OAuth2 Proxy API Token has been invalidated because of inactivity or expired token lifespan"); + } + mailManager.send(mail); + } catch (EmailException e) { + log.warn("Failed to send notification email about oauth2 API token reset to user " + user.getName()); + log.debug("Failed to send notification email", e); + } + } else { + log.warn("Sending token invalidation notifications is enabled, but no mail server is configured in Nexus"); + } + } + } diff --git a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTaskDescriptor.java b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTaskDescriptor.java index 8849a65..5ff7e56 100644 --- a/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTaskDescriptor.java +++ b/src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTaskDescriptor.java @@ -5,6 +5,7 @@ import javax.inject.Singleton; import org.sonatype.nexus.common.upgrade.AvailabilityVersion; +import org.sonatype.nexus.formfields.CheckboxFormField; import org.sonatype.nexus.formfields.FormField; import org.sonatype.nexus.formfields.NumberTextFormField; import org.sonatype.nexus.scheduling.TaskDescriptorSupport; @@ -12,22 +13,43 @@ @AvailabilityVersion(from = "1.0") @Named @Singleton -public class OAuth2ProxyApiTokenInvalidateTaskDescriptor - extends TaskDescriptorSupport { +public class OAuth2ProxyApiTokenInvalidateTaskDescriptor extends TaskDescriptorSupport { public static final String TYPE_ID = "oauth2-proxy-api-token.cleanup"; - public static final String CONFIG_EXPIRY = TYPE_ID + "-expiry"; - private static final NumberTextFormField field = new NumberTextFormField(CONFIG_EXPIRY, - "Expiration in days", - "After this duration the API token will be overwritten and the user must renew it interactively.", - FormField.MANDATORY).withMinimumValue(1).withInitialValue(30); + public static final String CONFIG_IDLE_EXPIRY = TYPE_ID + "-expiry"; + public static final int CONFIG_IDLE_EXPIRY_DEFAULT = 30; + private static final NumberTextFormField maxIdleAge = new NumberTextFormField(CONFIG_IDLE_EXPIRY, // + "User idle time in days", // + "After the user has been inactive for this amount of days the API token will be overwritten and the user must renew it interactively. Setting this to 0 or a negative value disables max idle time entirely. Default is " + + CONFIG_IDLE_EXPIRY_DEFAULT + " days.", + FormField.MANDATORY)// + .withMinimumValue(1)// + .withInitialValue(CONFIG_IDLE_EXPIRY_DEFAULT); + + public static final String CONFIG_AGE = TYPE_ID + "-max-age"; + public static final int CONFIG_AGE_DEFAULT = -1; + private static final NumberTextFormField maxAge = new NumberTextFormField(CONFIG_AGE, // + "Max token age in days", // + "After this amount of days the API token will be overwritten and the user must renew it interactively. Setting this to 0 or a negative value disables max token age entirely. Default is " + + CONFIG_AGE_DEFAULT + " days.", + FormField.MANDATORY)// + .withInitialValue(CONFIG_AGE_DEFAULT); + + public static final String NOTIFY = TYPE_ID + "-notify"; + public static final Boolean NOTIFY_DEFAULT = false; + private static final CheckboxFormField notify = new CheckboxFormField(NOTIFY, // + "Send Email on token invalidation", // + "Defines whether an email is send to the affected user if their API token is invalidated automatically based on any condition. Default is " + + NOTIFY_DEFAULT, + FormField.OPTIONAL)// + .withInitialValue(NOTIFY_DEFAULT); @Inject public OAuth2ProxyApiTokenInvalidateTaskDescriptor() { super(TYPE_ID, OAuth2ProxyApiTokenInvalidateTask.class, "OAuth2 Proxy API token invalidator", - TaskDescriptorSupport.VISIBLE, TaskDescriptorSupport.EXPOSED, - TaskDescriptorSupport.REQUEST_RECOVERY, new FormField[] { field }); + TaskDescriptorSupport.VISIBLE, TaskDescriptorSupport.EXPOSED, TaskDescriptorSupport.REQUEST_RECOVERY, + new FormField[] { maxIdleAge, maxAge, notify }); } @Override diff --git a/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordStore.java b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordStore.java index d008120..d67ccf0 100644 --- a/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordStore.java +++ b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordStore.java @@ -2,8 +2,10 @@ import static com.github.tumbl3w33d.h2.OAuth2ProxyStores.loginRecordDAO; +import java.util.Collections; +import java.util.Map; import java.util.Optional; -import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -18,7 +20,6 @@ import org.sonatype.nexus.transaction.TransactionalStore; import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord; -import com.google.common.collect.ImmutableSet; @Named("mybatis") @Singleton @@ -28,8 +29,7 @@ public class OAuth2ProxyLoginRecordStore extends StateGuardLifecycleSupport private final DataSessionSupplier sessionSupplier; @Inject - public OAuth2ProxyLoginRecordStore( - final DataSessionSupplier sessionSupplier) { + public OAuth2ProxyLoginRecordStore(final DataSessionSupplier sessionSupplier) { this.sessionSupplier = sessionSupplier; } @@ -39,11 +39,9 @@ public DataSession openSession() { } @Transactional - public Set getAllLoginRecords() { - Set allRecords = StreamSupport.stream(loginRecordDAO().browse().spliterator(), false) - .collect(Collectors.toSet()); - - return ImmutableSet.copyOf(allRecords); + public Map getAllLoginRecords() { + return Collections.unmodifiableMap(StreamSupport.stream(loginRecordDAO().browse().spliterator(), false) + .collect(Collectors.toMap(OAuth2ProxyLoginRecord::getId, Function.identity()))); } @Transactional @@ -51,7 +49,7 @@ public Optional getLoginRecord(String userId) { log.trace("call to getLoginRecord with userId {}", userId); try { - return OAuth2ProxyStores.loginRecordDAO().read(userId); + return loginRecordDAO().read(userId); } catch (Exception e) { log.error("unable to retrieve login record for {} - {}", userId, e); throw e; diff --git a/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyStores.java b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyStores.java index d18a604..08d3e76 100644 --- a/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyStores.java +++ b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyStores.java @@ -32,4 +32,8 @@ public static OAuth2ProxyRoleDAO roleDAO() { public static OAuth2ProxyLoginRecordDAO loginRecordDAO() { return dao(OAuth2ProxyLoginRecordDAO.class); } + + public static OAuth2ProxyTokenInfoDAO tokenInfoDAO() { + return dao(OAuth2ProxyTokenInfoDAO.class); + } } diff --git a/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.java b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.java new file mode 100644 index 0000000..dfe6e54 --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.java @@ -0,0 +1,9 @@ +package com.github.tumbl3w33d.h2; + +import org.sonatype.nexus.datastore.api.IdentifiedDataAccess; + +import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo; + +public interface OAuth2ProxyTokenInfoDAO extends IdentifiedDataAccess { + +} diff --git a/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoStore.java b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoStore.java new file mode 100644 index 0000000..8d2a068 --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoStore.java @@ -0,0 +1,75 @@ +package com.github.tumbl3w33d.h2; + +import static com.github.tumbl3w33d.h2.OAuth2ProxyStores.tokenInfoDAO; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.sonatype.nexus.common.stateguard.StateGuardLifecycleSupport; +import org.sonatype.nexus.datastore.api.DataSession; +import org.sonatype.nexus.datastore.api.DataSessionSupplier; +import org.sonatype.nexus.transaction.Transactional; +import org.sonatype.nexus.transaction.TransactionalStore; + +import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo; + +@Named("mybatis") +@Singleton +public class OAuth2ProxyTokenInfoStore extends StateGuardLifecycleSupport + implements TransactionalStore> { + + private final DataSessionSupplier sessionSupplier; + + @Inject + public OAuth2ProxyTokenInfoStore(final DataSessionSupplier sessionSupplier) { + this.sessionSupplier = sessionSupplier; + } + + @Override + public DataSession openSession() { + return OAuth2ProxyStores.openSession(sessionSupplier); + } + + @Transactional + public Map getAllTokenInfos() { + return Collections.unmodifiableMap(StreamSupport.stream(tokenInfoDAO().browse().spliterator(), false) + .collect(Collectors.toMap(OAuth2ProxyTokenInfo::getId, Function.identity()))); + } + + @Transactional + public Optional getTokenInfo(String userId) { + log.trace("call to getTokenInfo with userId {}", userId); + + try { + return tokenInfoDAO().read(userId); + } catch (Exception e) { + log.error("unable to retrieve token record for {} - {}", userId, e); + throw e; + } + } + + @Transactional + public void createTokenInfo(String userId) { + log.trace("call to createTokenInfo with userId {}", userId); + try { + tokenInfoDAO().create(OAuth2ProxyTokenInfo.of(userId)); + } catch (Exception e) { + log.error("unable to create token record for {} - {}", userId, e); + throw e; + } + } + + @Transactional + public void updateTokenInfo(String userId) { + tokenInfoDAO().update(OAuth2ProxyTokenInfo.of(userId)); + } + +} diff --git a/src/main/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManager.java b/src/main/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManager.java index c09979c..fa612e3 100644 --- a/src/main/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManager.java +++ b/src/main/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManager.java @@ -19,6 +19,7 @@ import org.sonatype.nexus.security.user.UserStatus; import com.github.tumbl3w33d.OAuth2ProxyRealm; +import com.github.tumbl3w33d.h2.OAuth2ProxyTokenInfoStore; import com.github.tumbl3w33d.h2.OAuth2ProxyUserStore; import com.github.tumbl3w33d.users.db.OAuth2ProxyUser; @@ -31,10 +32,13 @@ public class OAuth2ProxyUserManager extends AbstractUserManager { public static final String SOURCE = "OAuth2Proxy"; private final OAuth2ProxyUserStore userStore; + private final OAuth2ProxyTokenInfoStore tokenInfoStore; @Inject - public OAuth2ProxyUserManager(final OAuth2ProxyUserStore userStore) { + public OAuth2ProxyUserManager(final OAuth2ProxyUserStore userStore, + @Named final OAuth2ProxyTokenInfoStore tokenInfoStore) { this.userStore = userStore; + this.tokenInfoStore = tokenInfoStore; } @Override @@ -179,6 +183,7 @@ public void changePassword(String userId, String newPassword) throws UserNotFoun if (maybeUserToUpdate.isPresent()) { userStore.updateUserApiToken(userId, newPassword); + tokenInfoStore.updateTokenInfo(userId); } else { log.warn("could not retrieve user {} for changing password", userId); } diff --git a/src/main/java/com/github/tumbl3w33d/users/db/OAuth2ProxyTokenInfo.java b/src/main/java/com/github/tumbl3w33d/users/db/OAuth2ProxyTokenInfo.java new file mode 100644 index 0000000..b000ea7 --- /dev/null +++ b/src/main/java/com/github/tumbl3w33d/users/db/OAuth2ProxyTokenInfo.java @@ -0,0 +1,42 @@ +package com.github.tumbl3w33d.users.db; + +import java.io.Serializable; +import java.sql.Timestamp; + +import org.joda.time.Instant; +import org.sonatype.nexus.common.entity.AbstractEntity; +import org.sonatype.nexus.common.entity.HasStringId; + +public class OAuth2ProxyTokenInfo extends AbstractEntity implements Serializable, HasStringId { + + private static final long serialVersionUID = -2052302452536776751L; + + private String userId; + private Timestamp tokenCreation; + + public Timestamp getTokenCreation() { + return tokenCreation; + } + + public void setTokenCreation(Timestamp tokenCreation) { + this.tokenCreation = tokenCreation; + } + + @Override + public String getId() { + return this.userId; + } + + @Override + public void setId(String id) { + this.userId = id; + } + + public static OAuth2ProxyTokenInfo of(String userId) { + OAuth2ProxyTokenInfo newRecord = new OAuth2ProxyTokenInfo(); + newRecord.setId(userId); + newRecord.setTokenCreation(new Timestamp(Instant.now().getMillis())); + return newRecord; + } + +} diff --git a/src/main/resources/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.xml b/src/main/resources/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.xml new file mode 100644 index 0000000..caaa125 --- /dev/null +++ b/src/main/resources/com/github/tumbl3w33d/h2/OAuth2ProxyTokenInfoDAO.xml @@ -0,0 +1,49 @@ + + + + + + + + + + CREATE TABLE IF NOT EXISTS oauth2_proxy_token_info ( + userId VARCHAR(255) NOT NULL, + tokenCreation TIMESTAMP NOT NULL, + PRIMARY KEY (userId) + ); + + -- Seed this table by assuming the last login was when the token was created. While not correct, this is better than nothing + INSERT INTO oauth2_proxy_token_info (userId, tokenCreation) + SELECT userId, lastLogin FROM oauth2_proxy_login_record; + + + + + INSERT INTO oauth2_proxy_token_info (userId, tokenCreation) + VALUES (#{userId, jdbcType=VARCHAR}, #{tokenCreation, jdbcType=TIMESTAMP}) + + + + UPDATE oauth2_proxy_token_info + SET tokenCreation = #{tokenCreation, jdbcType=TIMESTAMP} + WHERE userId = #{userId, jdbcType=VARCHAR} + + + + + + DELETE FROM oauth2_proxy_token_info + WHERE userId = #{userId} + + + + + \ No newline at end of file diff --git a/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java b/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java index de84563..0cb4a2b 100644 --- a/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java +++ b/src/test/java/com/github/tumbl3w33d/users/OAuth2ProxyUserManagerTest.java @@ -20,6 +20,7 @@ import org.sonatype.nexus.security.user.UserSearchCriteria; import org.sonatype.nexus.security.user.UserStatus; +import com.github.tumbl3w33d.h2.OAuth2ProxyTokenInfoStore; import com.github.tumbl3w33d.h2.OAuth2ProxyUserStore; import com.github.tumbl3w33d.users.db.OAuth2ProxyUser; import com.google.common.collect.ImmutableSet; @@ -119,7 +120,8 @@ void testGetUser() throws UserNotFoundException { User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); Mockito.when(userStore.getUser(anyString())).thenReturn(Optional.of(exampleUser)); - OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore); + OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, + Mockito.mock(OAuth2ProxyTokenInfoStore.class)); assertDoesNotThrow(() -> { User user = userManager.getUser(exampleUser.getUserId()); @@ -134,7 +136,8 @@ void testGetUserForNonExisting() throws UserNotFoundException { User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); Mockito.when(userStore.getUser(anyString())).thenReturn(Optional.empty()); - OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore); + OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, + Mockito.mock(OAuth2ProxyTokenInfoStore.class)); assertThrows(UserNotFoundException.class, () -> userManager.getUser(exampleUser.getUserId())); @@ -146,7 +149,8 @@ void testGetUserWithRoleIds() { User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); Mockito.when(userStore.getUser(anyString())).thenReturn(Optional.of(exampleUser)); - OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore); + OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, + Mockito.mock(OAuth2ProxyTokenInfoStore.class)); assertDoesNotThrow(() -> { User user = userManager.getUser(exampleUser.getUserId(), @@ -165,7 +169,8 @@ void testListUserIds() { "test.user2@example.com"); ImmutableSet testUsers = ImmutableSet.of(exampleUser1, exampleUser2); Mockito.when(userStore.getAllUsers()).thenReturn(testUsers); - OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore); + OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, + Mockito.mock(OAuth2ProxyTokenInfoStore.class)); assertEquals(ImmutableSet.of("test.user1", "test.user2"), userManager.listUserIds()); @@ -180,7 +185,8 @@ void testListUsers() { "test.user2@example.com"); ImmutableSet testUsers = ImmutableSet.of(exampleUser1, exampleUser2); Mockito.when(userStore.getAllUsers()).thenReturn(testUsers); - OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore); + OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, + Mockito.mock(OAuth2ProxyTokenInfoStore.class)); assertEquals(testUsers, userManager.listUsers()); } @@ -191,7 +197,8 @@ void testSearchUsers() { User exampleUser = OAuth2ProxyUserManager.createUserObject("test.user", "test.user@example.com"); Mockito.when(userStore.getAllUsers()).thenReturn(ImmutableSet.of(exampleUser)); - OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore); + OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, + Mockito.mock(OAuth2ProxyTokenInfoStore.class)); Set searchResult = userManager.searchUsers(new UserSearchCriteria("test.user")); assertTrue(searchResult.size() == 1); @@ -251,7 +258,8 @@ private OAuth2ProxyUserManager getTestUserManager(OAuth2ProxyUserStore userStore userStore = Mockito.mock(OAuth2ProxyUserStore.class); } - OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore); + OAuth2ProxyUserManager userManager = new OAuth2ProxyUserManager(userStore, + Mockito.mock(OAuth2ProxyTokenInfoStore.class)); return userManager; } }