diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cdda6a..9e6c856 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Find changes for the upcoming release in the project's [changelog.d](https://git + +## 2.4.5 (2024-07-24) + +### Changed + +- Introduce RubinIdentityManagerImpl, extends IdentityManager which replaces the deprecated Authenticator +- Added deprecated AuthenticatorImpl, this is only useful in case this version of TAP is used with the old Auth params/implementations (Unlikely) + ## 2.4.4 (2024-07-23) diff --git a/src/main/java/org/opencadc/tap/impl/AuthenticatorImpl.java b/src/main/java/org/opencadc/tap/impl/AuthenticatorImpl.java new file mode 100644 index 0000000..22374fa --- /dev/null +++ b/src/main/java/org/opencadc/tap/impl/AuthenticatorImpl.java @@ -0,0 +1,152 @@ +package org.opencadc.tap.impl; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.net.http.HttpRequest; +import java.net.URI; +import java.security.AccessControlException; +import java.security.Principal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +import javax.security.auth.Subject; +import javax.security.auth.x500.X500Principal; + +import ca.nrc.cadc.auth.Authenticator; +import ca.nrc.cadc.auth.AuthMethod; +import ca.nrc.cadc.auth.AuthorizationTokenPrincipal; +import ca.nrc.cadc.auth.HttpPrincipal; +import ca.nrc.cadc.auth.NumericPrincipal; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import org.apache.log4j.Logger; + +/** + * @deprecated This class is deprecated and will be removed in future releases. + * The TAP Service now uses IdentityManager for authentication, available in the opencadc library + * + * @author cbanek + */ +@Deprecated +public class AuthenticatorImpl implements Authenticator +{ + private static final Logger log = Logger.getLogger(AuthenticatorImpl.class); + + // Size of the token cache is read from the maxTokenCache property, with + // a default of 1000 tokens cached. + private static final int maxTokenCache = Integer.getInteger("maxTokenCache", 1000); + + private static final String gafaelfawr_url = System.getProperty("gafaelfawr_url"); + + private static final HttpClient client = HttpClient.newHttpClient(); + + private static final ConcurrentHashMap tokenCache = new ConcurrentHashMap<>(); + + private final class TokenInfo + { + public final String username; + public final int uid; + + public TokenInfo(String username, int uid) + { + this.username = username; + this.uid = uid; + } + } + + public AuthenticatorImpl() + { + } + + public Subject validate(Subject subject) throws AccessControlException { + log.debug("Subject to augment starts as: " + subject); + + // Check if the cache is too big, and if so, clear it out. + if (tokenCache.size() > maxTokenCache) { + tokenCache.clear(); + } + + List addedPrincipals = new ArrayList(); + AuthorizationTokenPrincipal tokenPrincipal = null; + + for (Principal principal : subject.getPrincipals()) { + if (principal instanceof AuthorizationTokenPrincipal) { + tokenPrincipal = (AuthorizationTokenPrincipal) principal; + TokenInfo tokenInfo = null; + + for (int i = 1; i < 5 && tokenInfo == null; i++) { + try { + tokenInfo = getTokenInfo(tokenPrincipal.getHeaderValue()); + } catch (IOException|InterruptedException e) { + log.warn("Exception thrown while getting info from Gafaelfawr"); + log.warn(e); + } + } + + if (tokenInfo != null) { + X500Principal xp = new X500Principal("CN=" + tokenInfo.username); + addedPrincipals.add(xp); + + HttpPrincipal hp = new HttpPrincipal(tokenInfo.username); + addedPrincipals.add(hp); + + UUID uuid = new UUID(0L, (long) tokenInfo.uid); + NumericPrincipal np = new NumericPrincipal(uuid); + addedPrincipals.add(np); + } + else { + log.error("Gave up retrying user-info requests to Gafaelfawr"); + } + } + } + + if (tokenPrincipal != null) { + subject.getPrincipals().remove(tokenPrincipal); + } + + subject.getPrincipals().addAll(addedPrincipals); + subject.getPublicCredentials().add(AuthMethod.TOKEN); + + log.debug("Augmented subject is " + subject); + return subject; + } + + // Here we could check the token again, but gafaelfawr should be + // doing that for us already by the time it gets to us. So for + // this layer, we just let this go through. + public Subject augment(Subject subject) { + return subject; + } + + private TokenInfo getTokenInfo(String token) throws IOException, InterruptedException { + // If the request has gotten this far, the token has already + // been checked upstream, so we know it's valid, we just need + // to determine the uid and the username. + if (!tokenCache.containsKey(token)) { + HttpRequest request = HttpRequest.newBuilder(URI.create(gafaelfawr_url)) + .header("Accept", "application/json") + .header("Authorization", token) + .build(); + + HttpResponse response = client.send(request, BodyHandlers.ofString()); + String body = response.body(); + + Gson gson = new Gson(); + JsonObject authData = gson.fromJson(body, JsonObject.class); + String username = authData.getAsJsonPrimitive("username").getAsString(); + int uid = authData.getAsJsonPrimitive("uid").getAsInt(); + + // Insert the info into the cache here since we retrieved it. + tokenCache.put(token, new TokenInfo(username, uid)); + } + + return tokenCache.get(token); + } +} diff --git a/src/main/java/org/opencadc/tap/impl/RubinIdentityManagerImpl.java b/src/main/java/org/opencadc/tap/impl/RubinIdentityManagerImpl.java new file mode 100644 index 0000000..1210a81 --- /dev/null +++ b/src/main/java/org/opencadc/tap/impl/RubinIdentityManagerImpl.java @@ -0,0 +1,238 @@ +package org.opencadc.tap.impl; + +import java.io.IOException; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.net.http.HttpRequest; +import java.net.URI; +import java.security.AccessControlException; +import java.security.Principal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Collections; + +import javax.security.auth.Subject; +import javax.security.auth.x500.X500Principal; + +import ca.nrc.cadc.auth.AuthMethod; +import ca.nrc.cadc.auth.AuthenticationUtil; +import ca.nrc.cadc.auth.Authenticator; +import ca.nrc.cadc.auth.AuthMethod; +import ca.nrc.cadc.auth.IdentityManager; +import ca.nrc.cadc.reg.Standards; + +import ca.nrc.cadc.auth.AuthorizationTokenPrincipal; +import ca.nrc.cadc.auth.HttpPrincipal; +import ca.nrc.cadc.auth.NumericPrincipal; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import org.apache.log4j.Logger; + +/** + * Implementes the Authenticator for processing Gafaelfawr auth, + * and using it to authenticate against the TAP service. + * + * The token in the authorization header is used to make a call + * to Gafaelfawr to retrieve details such as the uid and uidNumber. + * + * @author cbanek + * @author stvoutsin + */ +public class RubinIdentityManagerImpl implements IdentityManager +{ + private static final Logger log = Logger.getLogger(RubinIdentityManagerImpl.class); + + // Size of the token cache is read from the maxTokenCache property, with + // a default of 1000 tokens cached. + private static final int maxTokenCache = Integer.getInteger("maxTokenCache", 1000); + + private static final String gafaelfawr_url = System.getProperty("gafaelfawr_url"); + + private static final HttpClient client = HttpClient.newHttpClient(); + + private static final ConcurrentHashMap tokenCache = new ConcurrentHashMap<>(); + + private final class TokenInfo + { + public final String username; + public final int uid; + + public TokenInfo(String username, int uid) + { + this.username = username; + this.uid = uid; + } + } + @Override + public Set getSecurityMethods() { + return SEC_METHODS; + } + private static final Set SEC_METHODS; + + static { + Set tmp = new TreeSet<>(); + tmp.add(Standards.SECURITY_METHOD_ANON); + tmp.add(Standards.SECURITY_METHOD_TOKEN); + SEC_METHODS = Collections.unmodifiableSet(tmp); + } + + public RubinIdentityManagerImpl() + { + } + + public Subject validate(Subject subject) throws AccessControlException { + log.debug("Subject to augment starts as: " + subject); + + // Check if the cache is too big, and if so, clear it out. + if (tokenCache.size() > maxTokenCache) { + tokenCache.clear(); + } + + List addedPrincipals = new ArrayList(); + AuthorizationTokenPrincipal tokenPrincipal = null; + + for (Principal principal : subject.getPrincipals()) { + if (principal instanceof AuthorizationTokenPrincipal) { + tokenPrincipal = (AuthorizationTokenPrincipal) principal; + TokenInfo tokenInfo = null; + + for (int i = 1; i < 5 && tokenInfo == null; i++) { + try { + tokenInfo = getTokenInfo(tokenPrincipal.getHeaderValue()); + } catch (IOException|InterruptedException e) { + log.warn("Exception thrown while getting info from Gafaelfawr"); + log.warn(e); + } + } + + if (tokenInfo != null) { + X500Principal xp = new X500Principal("CN=" + tokenInfo.username); + addedPrincipals.add(xp); + + HttpPrincipal hp = new HttpPrincipal(tokenInfo.username); + addedPrincipals.add(hp); + + UUID uuid = new UUID(0L, (long) tokenInfo.uid); + NumericPrincipal np = new NumericPrincipal(uuid); + addedPrincipals.add(np); + } + else { + log.error("Gave up retrying user-info requests to Gafaelfawr"); + } + } + } + + if (tokenPrincipal != null) { + subject.getPrincipals().remove(tokenPrincipal); + } + + subject.getPrincipals().addAll(addedPrincipals); + subject.getPublicCredentials().add(AuthMethod.TOKEN); + + log.debug("Augmented subject is " + subject); + return subject; + } + + // Here we could check the token again, but gafaelfawr should be + // doing that for us already by the time it gets to us. So for + // this layer, we just let this go through. + public Subject augment(Subject subject) { + return subject; + } + + private TokenInfo getTokenInfo(String token) throws IOException, InterruptedException { + // If the request has gotten this far, the token has already + // been checked upstream, so we know it's valid, we just need + // to determine the uid and the username. + if (!tokenCache.containsKey(token)) { + HttpRequest request = HttpRequest.newBuilder(URI.create(gafaelfawr_url)) + .header("Accept", "application/json") + .header("Authorization", token) + .build(); + + HttpResponse response = client.send(request, BodyHandlers.ofString()); + String body = response.body(); + + Gson gson = new Gson(); + JsonObject authData = gson.fromJson(body, JsonObject.class); + String username = authData.getAsJsonPrimitive("username").getAsString(); + int uid = authData.getAsJsonPrimitive("uid").getAsInt(); + + // Insert the info into the cache here since we retrieved it. + tokenCache.put(token, new TokenInfo(username, uid)); + } + + return tokenCache.get(token); + } + + + @Override + public Subject toSubject(Object owner) { + Subject ret = new Subject(); + if (owner != null) { + UUID uuid = null; + if (owner instanceof UUID) { + uuid = (UUID) owner; + } else if (owner instanceof String) { + String sub = (String) owner; + uuid = UUID.fromString(sub); + } else { + throw new RuntimeException("unexpected owner type: " + owner.getClass().getName() + " value: " + owner); + } + NumericPrincipal p = new NumericPrincipal(uuid); + + // effectively augment by using the current subject as a "cache" of known identities + Subject s = AuthenticationUtil.getCurrentSubject(); + if (s != null) { + for (Principal cp : s.getPrincipals()) { + if (AuthenticationUtil.equals(p, cp)) { + log.debug("[cache hit] caller Subject matches " + p + ": " + s); + ret.getPrincipals().addAll(s.getPrincipals()); + return ret; + } + } + } + + ret.getPrincipals().add(p); + // this is sufficient for some purposes, but not for output using toDisplayString (eg vospace node owner) + // TODO: use PosixMapperClient.augment() to try to add a PosixPrincipal and infer an HttpPrincipal? + } + return ret; + } + + @Override + public Object toOwner(Subject subject) { + // use NumericPrincipal aka OIDC sub for persistence + Set ps = subject.getPrincipals(NumericPrincipal.class); + if (ps.isEmpty()) { + return null; + } + return ps.iterator().next().getUUID().toString(); + } + + @Override + public String toDisplayString(Subject subject) { + if (subject != null) { + // prefer HttpPrincipal aka OIDC preferred_username for string output, eg logging + Set ps = subject.getPrincipals(HttpPrincipal.class); + if (!ps.isEmpty()) { + return ps.iterator().next().getName(); // kind of ugh + } + + // default + Set ps2 = subject.getPrincipals(); + if (!ps2.isEmpty()) { + return ps2.iterator().next().getName(); + } + } + + return null; + } +}