Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow configuring max age and expiry notifications for login tokens #28

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,88 +5,164 @@
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<UserManager> 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<String, OAuth2ProxyLoginRecord> loginRecords = loginRecordStore.getAllLoginRecords();
Map<String, OAuth2ProxyTokenInfo> tokenInfos = tokenInfoStore.getAllTokenInfos();

Set<OAuth2ProxyLoginRecord> 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<String> 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
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");
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,51 @@
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;

@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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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
Expand All @@ -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;
}

Expand All @@ -39,19 +39,17 @@ public DataSession<?> openSession() {
}

@Transactional
public Set<OAuth2ProxyLoginRecord> getAllLoginRecords() {
Set<OAuth2ProxyLoginRecord> allRecords = StreamSupport.stream(loginRecordDAO().browse().spliterator(), false)
.collect(Collectors.toSet());

return ImmutableSet.copyOf(allRecords);
public Map<String, OAuth2ProxyLoginRecord> getAllLoginRecords() {
return Collections.unmodifiableMap(StreamSupport.stream(loginRecordDAO().browse().spliterator(), false)
.collect(Collectors.toMap(OAuth2ProxyLoginRecord::getId, Function.identity())));
}

@Transactional
public Optional<OAuth2ProxyLoginRecord> 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;
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyStores.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,8 @@ public static OAuth2ProxyRoleDAO roleDAO() {
public static OAuth2ProxyLoginRecordDAO loginRecordDAO() {
return dao(OAuth2ProxyLoginRecordDAO.class);
}

public static OAuth2ProxyTokenInfoDAO tokenInfoDAO() {
return dao(OAuth2ProxyTokenInfoDAO.class);
}
}
Original file line number Diff line number Diff line change
@@ -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<OAuth2ProxyTokenInfo> {

}
Loading
Loading