Skip to content
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
159 changes: 159 additions & 0 deletions cwms-data-api/src/main/java/cwms/cda/security/BatchJobContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package cwms.cda.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.flogger.FluentLogger;
import cwms.cda.ApiServlet;
import cwms.cda.datasource.ConnectionPreparingDataSource;
import cwms.cda.datasource.ConnectionPreparer;
import cwms.cda.datasource.DelegatingConnectionPreparer;
import cwms.cda.datasource.SessionOfficePreparer;
import io.javalin.http.Context;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import javax.sql.DataSource;
import javax.servlet.http.HttpServletResponse;

public final class BatchJobContext {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

public static final String HEADER = "X-CWMS-Job-Context";
public static final String RUN_AS_OFFICE_ATTR = "BatchRunAsOffice";

public static final String SECRET_PROPERTY = "cwms.dataapi.batch.jobContext.secret";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is Batch becoming an issuer of secrets?

My understanding is that Keycloak would provide the JWT and CDA would just consume it.

public static final String PREVIOUS_SECRET_PROPERTY = "cwms.dataapi.batch.jobContext.previousSecret";
public static final String KEY_ID_PROPERTY = "cwms.dataapi.batch.jobContext.keyId";
public static final String ISSUER_PROPERTY = "cwms.dataapi.batch.jobContext.issuer";
public static final String AUDIENCE_PROPERTY = "cwms.dataapi.batch.jobContext.audience";
public static final String MACHINE_USERS_PROPERTY = "cwms.dataapi.batch.machineUsers";

private static final String DEFAULT_ISSUER = "cwms-batch-events";
private static final String DEFAULT_AUDIENCE = "cwms-data-api";
private static final String DEFAULT_MACHINE_USERS = "";

private BatchJobContext() {
}

public static boolean isBatchMachineUser(String username) {
if (username == null) {
return false;
}
String machineUsers = readSetting(MACHINE_USERS_PROPERTY, DEFAULT_MACHINE_USERS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CDA should not need to know anything about this, the JWT provided by Keycloak can embed a claim of "machine-auth" or something and decisions made from that.

if (machineUsers.isBlank()) {
return false;
}
for (String machineUser : machineUsers.split(",")) {
if (username.equalsIgnoreCase(machineUser.trim())) {
return true;
}
}
return false;
}

public static void prepareContext(Context ctx, DataApiPrincipal principal) throws CwmsAuthException {
if (!isBatchMachineUser(principal.getName())) {
return;
}

String token = ctx.header(HEADER);
if (token == null || token.isBlank()) {
throw new CwmsAuthException("Batch machine request missing signed job context",
HttpServletResponse.SC_UNAUTHORIZED);
}

try {
Claims claims = parse(token);
String office = claims.get("run_as_office", String.class);
if (office == null || office.isBlank()) {
office = claims.get("office", String.class);
}
if (office == null || office.isBlank()) {
throw new CwmsAuthException("Batch job context missing run_as_office",
HttpServletResponse.SC_UNAUTHORIZED);
}
ctx.attribute(RUN_AS_OFFICE_ATTR, office.toUpperCase(Locale.ROOT));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While appropriate to put in to a future logging context... these shouldn't need to be part of the Request/Response attributes. Downstream components needing to know that is a definite code smell.

} catch (ExpiredJwtException ex) {
logger.atFine().withCause(ex).log("Batch job context token expired.");
throw new CwmsAuthException("Batch job context token expired", ex,
HttpServletResponse.SC_UNAUTHORIZED);
} catch (JwtException | IllegalArgumentException ex) {
logger.atFine().withCause(ex).log("Batch job context token validation failed.");
throw new CwmsAuthException("Batch job context token not valid", ex,
HttpServletResponse.SC_UNAUTHORIZED);
}
}

public static void applyRunContext(Context ctx) {
String runAsOffice = ctx.attribute(RUN_AS_OFFICE_ATTR);
if (runAsOffice == null || runAsOffice.isBlank()) {
return;
}

DataSource dataSource = ctx.attribute(ApiServlet.DATA_SOURCE);
ConnectionPreparer officePreparer = new SessionOfficePreparer(runAsOffice);
if (dataSource instanceof ConnectionPreparingDataSource) {
ConnectionPreparingDataSource preparingDataSource = (ConnectionPreparingDataSource) dataSource;
preparingDataSource.setPreparer(new DelegatingConnectionPreparer(
preparingDataSource.getPreparer(), officePreparer));
} else {
ctx.attribute(ApiServlet.DATA_SOURCE,
new ConnectionPreparingDataSource(officePreparer, dataSource));
}
}

private static Claims parse(String token) {
String secret = secretForToken(token);
if (secret.length() < 32) {
throw new IllegalArgumentException("Batch job context secret must be at least 32 characters");
}
Key key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
return Jwts.parserBuilder()
.requireIssuer(readSetting(ISSUER_PROPERTY, DEFAULT_ISSUER))
.requireAudience(readSetting(AUDIENCE_PROPERTY, DEFAULT_AUDIENCE))
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}

private static String secretForToken(String token) {
String expectedKeyId = readSetting(KEY_ID_PROPERTY, "current");
String keyId = keyIdForToken(token);
if (keyId == null || keyId.isBlank() || expectedKeyId.equals(keyId)) {
return readSetting(SECRET_PROPERTY, "");
}
if ("previous".equals(keyId)) {
return readSetting(PREVIOUS_SECRET_PROPERTY, "");
}
throw new IllegalArgumentException("Batch job context key id is not recognized");
}

private static String keyIdForToken(String token) {
String[] parts = token.split("\\.");
if (parts.length != 3) {
throw new IllegalArgumentException("Batch job context token is malformed");
}
try {
byte[] headerBytes = Base64.getUrlDecoder().decode(parts[0]);
Map<?, ?> header = OBJECT_MAPPER.readValue(headerBytes, Map.class);
Object keyId = header.get("kid");
return keyId instanceof String ? (String) keyId : null;
} catch (IllegalArgumentException | IOException e) {
throw new IllegalArgumentException("Batch job context token header is malformed", e);
}
}

private static String readSetting(String key, String defaultValue) {
String value = System.getProperty(key, System.getenv(key));
return value == null || value.isBlank() ? defaultValue : value;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public void manage(Handler handler, Context ctx, Set<RouteRole> routeRoles) thr
}
checkRateLimit(ctx);
prepareContext(ctx, principal);
BatchJobContext.applyRunContext(ctx);
handler.handle(ctx);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,20 @@ private DataApiPrincipal getUserFromToken(Context ctx) throws CwmsAuthException
AuthDao dao = AuthDao.getInstance(JooqDao.getDslContext(ctx), ctx.attribute(ApiServlet.OFFICE_ID));
Optional<DataApiPrincipal> principal = dao.getPrincipalFromPrincipal(oidcPrincipal);
if (principal.isPresent()) {
return principal.get();
DataApiPrincipal dataApiPrincipal = principal.get();
BatchJobContext.prepareContext(ctx, dataApiPrincipal);
return dataApiPrincipal;
} else if (CREATE_USERS) {
final String preferredUserName = claims.get(PREFERRED_USERNAME_CLAIM, String.class);
if (BatchJobContext.isBatchMachineUser(preferredUserName)) {
throw new CwmsAuthException("Batch machine principal is not registered",
HttpServletResponse.SC_UNAUTHORIZED);
}
final String givenName = claims.get(GIVEN_NAME_CLAIM, String.class);
final String email = claims.get(EMAIL_CLAIM, String.class);
return dao.createUser(preferredUserName, oidcPrincipal, givenName, email);
DataApiPrincipal dataApiPrincipal = dao.createUser(preferredUserName, oidcPrincipal, givenName, email);
BatchJobContext.prepareContext(ctx, dataApiPrincipal);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the "batch" user is getting created randomly here, we have an issue, In the context of a batch process this should be a failure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected in bc40f8b

return dataApiPrincipal;
} else {
throw new CwmsAuthException("Not Authorized",HttpServletResponse.SC_UNAUTHORIZED);
}
Expand Down
Loading
Loading