Skip to content

Commit

Permalink
Rewrite to own user db (#5)
Browse files Browse the repository at this point in the history
* Rewrite to own user db

Token endpoint replaced with more generic user resource which also allows deleting oauth2proxy users (because the original user deletion API has their 2 internal sources hardcoded and rejects all others).

The realm also handles username + password for programmatic access now. It makes use of an hashed api token column in the new dedicated user db.

IDP group to nexus role mapping works without the name prefix. There's a dedicated db that collects the groups of all users logging in. Then they can be used with the original external role mapping mechanism of nexus.

* Let's not log credentials

* Set the random api token of new users before persisting
  • Loading branch information
tumbl3w33d authored Jun 4, 2024
1 parent 177e7d2 commit 8ecf60e
Show file tree
Hide file tree
Showing 24 changed files with 1,961 additions and 257 deletions.
23 changes: 11 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,26 @@ This plugin has been developed to facilitate the integration of Nexus with any i

Rather than executing its own OIDC (OpenID Connect) authentication flow, this plugin leverages OAuth2 Proxy to undertake the authentication process, relying on it to provide the necessary information through headers.

Furthermore, acknowledging the importance of non-interactive programmatic access within the Nexus environment, this plugin incorporates an API token feature. This feature ingeniously utilizes the password field of a local user object for storage purposes (acknowledged as a makeshift solution, with potential revisions anticipated). While users are unable to modify their passwords without prior knowledge of the existing one, the plugin introduces an additional endpoint. This endpoint enables authenticated users to reset their password to a system-generated one via the Nexus UI, with the caveat that this token is displayed solely once and is subject to reset with each access of the user menu item.
Furthermore, acknowledging the importance of non-interactive programmatic access within the Nexus environment, this plugin incorporates an API token feature. The plugin introduces an additional endpoint that allows authenticated users to reset their own API token to a system-generated one via the Nexus UI, with the caveat that this token is displayed solely once and is subject to reset with each access of this user menu item.

## ⚠️ State of Development & Disclaimer
## ⚠️ Disclaimer

The plugin currently encompasses the essential components required for operational functionality and is presently undergoing a testing phase. It is important to highlight that this plugin is provided on an 'as-is' basis, without any form of express or implied warranty. Under no circumstances shall the authors be held accountable for any damages or liabilities arising from the utilization of this plugin. Users are advised to proceed at their own risk.
It is important to highlight that this plugin is provided on an 'as-is' basis, without any form of express or implied warranty. Under no circumstances shall the authors be held accountable for any damages or liabilities arising from the utilization of this plugin. Users are advised to proceed at their own risk.

## Features

* makes use of several headers sent by OAuth2 proxy (depending on its configuration)
* see constants in [OAuth2ProxyHeaderAuthTokenFactory](src/main/java/com/github/tumbl3w33d/OAuth2ProxyHeaderAuthTokenFactory.java)
* creates an AuthenticationToken used by Nexus
* creates a user in the local Nexus database if none with the given id (`preferred_username`) exists
* creates a user in a dedicated database (i.e., not the 'local db' of Nexus) if none with the given id (`preferred_username`) exists
* anyone authenticated with your identity provider can access Nexus
* you would control access by granting necessary scopes accessing OAuth2 Proxy only to eligible user groups
* user creation currently has a rather simplistic strategy to extract `<firstname>.<lastname>` from `preferred_username`
* reuses existing user object from another existing realm (e.g. LDAP)
* ⚠️ this might not be appropriate for your setup, do your own testing or make sure there is no other realm active that holds user objects
* group/scope to role sync
* if you configure OAuth2 Proxy with the well-known `groups` claim, it will retrieve that information from the identity provider
* the groups received in the related header will be mapped to roles that you need to (manually) create in Nexus
* be aware that, in order to distinguish between role mappings connected with this realm and others, all groups will be prefixed with `idp-`, so name your roles accordingly and the user will magically receive/lose them on every login
* Example: idp group `[email protected]` will map to `[email protected]` role in Nexus
* the groups received in the related header will be stored in a database and become available for the 'external role mapping' functionality
* automatic expiry of API tokens
* there is a configurable task that lets API tokens expire, so another interactive login by the user is necessary to renew it
* 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

**Note**: After authenticating with this realm, the logout button is non-operative, which is a common limitation with header-based authentication methods. To force a logout, you need to logout from your identity provider and/or delete the OAuth2 Proxy cookie if you must logout for some reason.
Expand All @@ -36,7 +32,7 @@ The plugin currently encompasses the essential components required for operation

You typically put an OAuth2 Proxy in front of your application and make sure that related `X-Forwarded-` headers do not reach the application other than those originating from the OAuth2 Proxy.

For non-interactive programmatic access you circumvent the OAuth2 Proxy and go straight to the Nexus application. To achieve that, you could check for the presence of an `Authorization: Basic` header earlier in the chain of proxies. In that case the required credentials are the user's id and the generated API token. It is handled by the default implementation of Nexus and not touched by this plugin.
For non-interactive programmatic access you circumvent the OAuth2 Proxy and go straight to the Nexus application. To achieve that, you could check for the presence of an `Authorization: Basic` header earlier in the chain of proxies. In that case the required credentials are the user's id and the generated API token.

## Example with HAProxy as entrypoint

Expand All @@ -51,7 +47,10 @@ frontend you-name-it
# circumvent oauth2 proxy for programmatic access
acl is_basic_auth hdr_beg(Authorization) -i basic
use_backend nexus if is_basic_auth
# clients often send a HEAD request without Authorization header first
# and this must reach nexus directly, else the OAuth2 dance starts
acl is_head method HEAD
use_backend nexus if is_basic_auth OR is_head
default_backend oauth2-proxy
Expand Down
9 changes: 6 additions & 3 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -91,28 +91,31 @@
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.10.2</version>
<scope>test</scope>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.2</version>
<scope>test</scope>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.valfirst</groupId>
<artifactId>slf4j-test</artifactId>
<version>3.0.1</version>
<scope>test</scope>
</dependency>

<!-- UI plugin part -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default function OAuth2ProxyApiTokenComponent() {
React.useEffect(() => {
(async () => {
try {
const reponse = await Axios.post("/service/rest/oauth2-proxy-api-token/reset-token");
const reponse = await Axios.post("/service/rest/oauth2-proxy/user/reset-token");
setToken(reponse.data);
} catch (e) {
setError(true);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@
import javax.inject.Inject;
import javax.inject.Named;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.sonatype.nexus.logging.task.TaskLogging;
import org.sonatype.nexus.orient.DatabaseInstance;
import org.sonatype.nexus.scheduling.Cancelable;
import org.sonatype.nexus.scheduling.TaskSupport;
import org.sonatype.nexus.security.user.UserManager;
import org.sonatype.nexus.security.user.UserNotFoundException;

import com.github.tumbl3w33d.users.OAuth2ProxyUserManager;
import com.orientechnologies.orient.core.db.document.ODatabaseDocumentTx;
import com.orientechnologies.orient.core.record.impl.ODocument;
import com.orientechnologies.orient.core.sql.query.OSQLSynchQuery;
Expand All @@ -34,30 +33,26 @@ public class OAuth2ProxyApiTokenInvalidateTask
extends TaskSupport
implements Cancelable {

private final Logger logger = LoggerFactory.getLogger(OAuth2ProxyApiTokenInvalidateTask.class.getName());

private final DatabaseInstance databaseInstance;
private final UserManager nexusAuthenticatingRealm;

private final OAuth2ProxyUserManager userManager;

@Inject
public OAuth2ProxyApiTokenInvalidateTask(@Named(OAuth2ProxyDatabase.NAME) DatabaseInstance databaseInstance,
final List<UserManager> userManagers) {
final List<UserManager> userManagers, final OAuth2ProxyUserManager userManager) {

this.databaseInstance = databaseInstance;

this.nexusAuthenticatingRealm = userManagers.stream()
.filter(um -> um.getAuthenticationRealmName() == "NexusAuthenticatingRealm")
.findFirst().get();
this.userManager = userManager;

OAuth2ProxyRealm.ensureUserLoginTimestampSchema(databaseInstance, log);
}

private void resetPassword(String userId) {
private void resetApiToken(String userId) {
try {
nexusAuthenticatingRealm.changePassword(userId, generateSecureRandomString(32));
logger.debug("Password reset for user {} succeeded", userId);
userManager.changePassword(userId, generateSecureRandomString(32));
log.debug("API token reset for user {} succeeded", userId);
} catch (UserNotFoundException e) {
logger.error("Unable to reset password of user {} - {}", userId, e);
log.error("Unable to reset API token of user {} - {}", userId, e);
}
}

Expand All @@ -70,7 +65,7 @@ protected Void execute() throws Exception {
"select from " + CLASS_USER_LOGIN));

if (userLogins.isEmpty()) {
logger.debug("Nothing to do");
log.debug("Nothing to do");
} else {
for (ODocument userLogin : userLogins) {
String userId = userLogin.field(FIELD_USER_ID);
Expand All @@ -79,34 +74,32 @@ protected Void execute() throws Exception {
Instant lastLoginInstant = lastLoginDate.toInstant();
Instant nowInstant = Instant.now();

logger.debug("Last known login for {} was {}", userId,
log.debug("Last known login for {} was {}", userId,
formatDateString(lastLoginDate));

long timePassed = ChronoUnit.DAYS.between(lastLoginInstant, nowInstant);

int configuredDuration = getConfiguration()
.getInteger(OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_EXPIRY, 1);

logger.debug("Time passed since login: {} - configured maximum: {}", timePassed,
log.debug("Time passed since login: {} - configured maximum: {}", timePassed,
configuredDuration);

if (timePassed >= configuredDuration) {
resetPassword(userId);
logger.info(
"Reset api token of user {} because they did not login via OAuth2 Proxy for a while",
userId);
resetApiToken(userId);
log.info("Reset api token of user {} because they did not show up for a while", userId);
}
}

}
} catch (Exception e) {
logger.error("Failed to retrieve login timestamps - {}", e);
log.error("Failed to retrieve login timestamps - {}", e);
}
return null;
}

@Override
public String getMessage() {
return "Invalidate OAuth2 Proxy API tokens of users who did not log in for a while";
return "Invalidate OAuth2 Proxy API tokens of users who did not show up for a while";
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/github/tumbl3w33d/OAuth2ProxyFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,10 @@ protected boolean onAccessDenied(ServletRequest request, ServletResponse respons

return false;
}

/* Only overriding to be able to mock it */
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
return super.executeLogin(request, response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public Object getPrincipal() {

@Override
public Object getCredentials() {
logger.warn("there are no credentials for oauth2 proxy authentication - returning null");
logger.trace("there are no credentials for oauth2 proxy authentication - returning null");
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ public class OAuth2ProxyHeaderAuthTokenFactory extends HttpHeaderAuthenticationT

private final Logger logger = LoggerFactory.getLogger(OAuth2ProxyHeaderAuthTokenFactory.class);

private static final String X_FORWARDED_USER = "X-Forwarded-User";
private static final String X_FORWARDED_PREFERRED_USERNAME = "X-Forwarded-Preferred-Username";
private static final String X_FORWARDED_EMAIL = "X-Forwarded-Email";
private static final String X_FORWARDED_ACCESS_TOKEN = "X-Forwarded-Access-Token";
private static final String X_FORWARDED_GROUPS = "X-Forwarded-Groups";
static final String X_FORWARDED_USER = "X-Forwarded-User";
static final String X_FORWARDED_PREFERRED_USERNAME = "X-Forwarded-Preferred-Username";
static final String X_FORWARDED_EMAIL = "X-Forwarded-Email";
static final String X_FORWARDED_ACCESS_TOKEN = "X-Forwarded-Access-Token";
static final String X_FORWARDED_GROUPS = "X-Forwarded-Groups";

static final List<String> OAUTH2_PROXY_HEADERS = Collections
.unmodifiableList(Arrays.asList(X_FORWARDED_USER, X_FORWARDED_PREFERRED_USERNAME,
Expand All @@ -47,7 +47,7 @@ public AuthenticationToken createToken(ServletRequest request, ServletResponse r
} else {
if (xForwardedUserHeader == null || xForwardedEmailHeader == null
|| xForwardedPrefUsernameHeader == null) {
logger.warn("required OAuth2 proxy headers incomplete - {}: {} - {}: {} - {}: {}",
logger.debug("required OAuth2 proxy headers incomplete - {}: {} - {}: {} - {}: {}",
X_FORWARDED_USER, xForwardedUserHeader,
X_FORWARDED_EMAIL, xForwardedEmailHeader,
X_FORWARDED_PREFERRED_USERNAME, xForwardedPrefUsernameHeader);
Expand Down
Loading

0 comments on commit 8ecf60e

Please sign in to comment.