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

Feature/354 openid auth #1007

Merged
merged 14 commits into from
Feb 21, 2025
Merged
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
12 changes: 6 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
FROM gradle:8.5-jdk8 as builder
FROM gradle:8.5-jdk8 AS builder
USER $USER
RUN --mount=type=cache,target=/home/gradle/.gradle
WORKDIR /builddir
COPY . /builddir/
RUN gradle clean prepareDockerBuild --info --no-daemon
RUN gradle prepareDockerBuild --info --no-daemon

FROM alpine:3.21.0 as tomcat_base
FROM alpine:3.21.0 AS tomcat_base
RUN apk --no-cache upgrade && \
apk --no-cache add \
openjdk8-jre \
Expand Down Expand Up @@ -41,10 +41,10 @@ ENV CDA_POOL_MAX_ACTIVE "30"
ENV CDA_POOL_MAX_IDLE "10"
ENV CDA_POOL_MIN_IDLE "5"
ENV cwms.dataapi.access.providers "KeyAccessManager,OpenID"
ENV cwms.dataapi.access.openid.wellKnownUrl "https://identity-test.cwbi.us/auth/realms/cwbi/.well-known/openid-configuration"
ENV cwms.dataapi.access.openid.issuer "https://identity-test.cwbi.us/auth/realms/cwbi"
ENV cwms.dataapi.access.openid.wellKnownUrl "https://<prefix>/.well-known/openid-configuration"
ENV cwms.dataapi.access.openid.issuer "<issuer>"
ENV cwms.dataapi.access.openid.timeout "604800"
ENV cwms.dataapi.access.openid.altAuthUrl "https://identityc-test.cwbi.us/auth/realms/cwbi"
#ENV cwms.dataapi.access.openid.altAuthUrl "https://identityc-test.cwbi.us/auth/realms/cwbi"

# used to simplify redeploy in certain contexts. Update to match -<marker> in image label
ENV IMAGE_MARKER="a"
Expand Down
25 changes: 22 additions & 3 deletions compose_files/keycloak/realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": false,
"registrationEmailAsUsername": false,
"rememberMe": false,
Expand Down Expand Up @@ -662,7 +662,8 @@
"alwaysDisplayInConsole": true,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"cwms-data.test"
"https://cwms-data.test:8444/*",
"https://localhost:5010/*"
],
"webOrigins": [
"*"
Expand All @@ -671,7 +672,7 @@
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"implicitFlowEnabled": true,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": false,
"publicClient": true,
Expand Down Expand Up @@ -2291,6 +2292,8 @@
{
"username": "m5hectest",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"credentials": [
{
"type": "password",
Expand All @@ -2300,6 +2303,22 @@
"realmRoles": [
"cwms_user"
]
},
{
"username": "q0hecoidc",
"enabled": true,
"email": "[email protected]",
"emailVerified": true,
"credentials": [
{
"type": "password",
"value": "q0hecoidc"
}
],
"realmRoles": [
"cwms_user",
"new_user"
]
}
]
}
4 changes: 2 additions & 2 deletions compose_files/sql/users.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
set define on
define OFFICE_EROC=&1
begin

begin
cwms_sec.add_user_to_group('&&OFFICE_EROC.webtest','All Users', 'HQ');
cwms_sec.add_user_to_group('&&OFFICE_EROC.webtest','All Users', 'SPK');
cwms_sec.add_user_to_group('&&OFFICE_EROC.webtest','CWMS Users', 'HQ');
Expand All @@ -21,6 +20,7 @@ begin
cwms_sec.add_cwms_user('m5hectest',NULL,'SWT');
cwms_sec.add_user_to_group('m5hectest','All Users', 'SWT');
cwms_sec.add_user_to_group('m5hectest','CWMS Users', 'SWT');
execute immediate 'grant excecute on cwms_upass to web_user';
end;
/
quit;
1 change: 1 addition & 0 deletions compose_files/tomcat/logging.properties
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ org.apache.catalina.util.LifecycleBase.handlers = java.util.logging.ConsoleHandl

org.apache.tomcat.jdbc.level = INFO
org.apache.tomcat.jdbc.handlers = java.util.logging.ConsoleHandler
cwms.cda.security.level = FINE
31 changes: 0 additions & 31 deletions compose_files/traefik/traefik.yml

This file was deleted.

2 changes: 2 additions & 0 deletions cwms-data-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ dependencies {
testImplementation(libs.cwms.tomcat.auth)
testImplementation(libs.apache.freemarker)

testRuntimeOnly("org.slf4j:slf4j-jdk14:2.0.16")

webjars(libs.swagger.ui) {
transitive = false
}
Expand Down
2 changes: 1 addition & 1 deletion cwms-data-api/src/main/java/cwms/cda/ApiServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ public void init() {
})
.routes(this::configureRoutes)
.javalinServlet();
logger.atInfo().log("Javalin initialized.");
}

private String obtainFullVersion(ServletConfig servletConfig) throws ServletException {
Expand All @@ -479,7 +480,6 @@ private CdaAccessManager buildAccessManager(String provider) {
} catch (ServiceNotFoundException err) {
throw new RuntimeException("Unable to initialize access manager",err);
}

}

protected void configureRoutes() {
Expand Down
85 changes: 85 additions & 0 deletions cwms-data-api/src/main/java/cwms/cda/data/dao/AuthDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@
import io.javalin.core.security.RouteRole;
import io.javalin.http.Context;
import io.javalin.http.HttpCode;
import usace.cwms.db.jooq.codegen.packages.cwms_sec.UPDATE_USER_DATA;

import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.sql.Connection;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
Expand Down Expand Up @@ -66,6 +69,13 @@ public class AuthDao extends Dao<DataApiPrincipal> {
private static final String USER_FOR_EDIPI =
"select userid from cwms_20.at_sec_cwms_users where edipi = ?";

// NOTE: the column name *should* be principal_name. It was spelled incorrectly and never changed for the life of the schema.
private static final String USER_EXISTS =
"select userid from cwms_20.at_sec_cwms_users where principle_name = ?";

private static final String ADD_CWMS_USER = "CALL cwms_20.cwms_sec.create_user(?,?,?,?)";
private static final String UPDATE_INFO = "CALL cwms_20.cwms_upass.update_user_data(?,?,null,null,null,?,?)";

public static final String CREATE_API_KEY = "insert into cwms_20.at_api_keys"
+ "(userid, key_name, apikey, created, expires) values(UPPER(?),?,?,?,?)";
public static final String REMOVE_API_KEY = "delete from cwms_20.at_api_keys "
Expand Down Expand Up @@ -243,6 +253,27 @@ private String userForEdipi(long edipi) throws CwmsAuthException {
}
}

private String userForPrincipal(String principal) throws CwmsAuthException {
try {
return dsl.connectionResult(c -> {
setSessionForAuthCheck(c);
try (PreparedStatement userForEdipi = c.prepareStatement(USER_EXISTS)) {
userForEdipi.setString(1, principal);
try (ResultSet rs = userForEdipi.executeQuery()) {
if (rs.next()) {
return rs.getString(1);
} else {
return null;
}
}
}
});
} catch (DataAccessException ex) {
logger.atInfo().withCause(ex).log("Unable to lookup user.");
throw new CwmsAuthException("Unable to lookup user.", ex);
}
}

/**
* Build a DataApiPrincipal from a given EDIPI value.
* @param edipi the Edipi value to look up.
Expand Down Expand Up @@ -493,4 +524,58 @@ public DataApiPrincipal getDataApiPrincipal(Context ctx) {
public void resetContext(DSLContext dslContext) {
this.dsl = dslContext;
}

/**
* Returns a principal from user if that user exists. otherwise empty optional
*
* Uses the "principle" column directly with the provided value as-is.
*
* @param principal provider + subject principal to lookup.
* @return
* @throws CwmsAuthException if anything goes wrong with the database query.
*/
public Optional<DataApiPrincipal> getPrincipalFromPrincipal(String principal) throws CwmsAuthException {
String user = userForPrincipal(principal);
if (user != null) {
Set<RouteRole> roles = this.getRolesForUser(user);
// In this case "cac_auth" just means the user is an actually user verify by some sort of
// identify management system. E.g. "not an apikey"
roles.add(new Role("cac_auth"));
return Optional.of(new DataApiPrincipal(user, roles));
} else {
return Optional.empty();
}
}


public DataApiPrincipal createUser(String username, String principal, String fullname, String email) throws CwmsAuthException {
try {
dsl.connection(c -> {
setSessionForAuthCheck(c);
try (PreparedStatement createUser = c.prepareStatement(ADD_CWMS_USER);
PreparedStatement updateData = c.prepareStatement(UPDATE_INFO)) {
createUser.setString(1, username);
createUser.setNull(2, Types.VARCHAR);
createUser.setNull(3, Types.ARRAY, "CWMS_T_CHAR_32_ARRAY");
createUser.setNull(4, Types.VARCHAR);
createUser.execute();

updateData.setString(1, username);
updateData.setString(2,fullname);
updateData.setString(3, email);
updateData.setString(4, principal);
updateData.execute();
}
});
Optional<DataApiPrincipal> apiPrincipal = getPrincipalFromPrincipal(principal);
if (apiPrincipal.isPresent()) {
return apiPrincipal.get();
} else {
throw new CwmsAuthException("User " + username + " was created, however no principal object could be created.");
}
} catch (DataAccessException ex) {
logger.atInfo().withCause(ex).log("Unable to create user " + username);
throw new CwmsAuthException("Unable to create user " + username, ex);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import java.util.Base64.Decoder;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.servlet.http.HttpServletResponse;
import org.jetbrains.annotations.NotNull;
Expand All @@ -42,6 +43,14 @@
public class OpenIDAccessManager extends CdaAccessManager {
private static final FluentLogger log = FluentLogger.forEnclosingClass();
public static final String AUTHORIZATION = "Authorization";
public static final String CREATE_USERS_KEY = "cwms.dataapi.access.openid.create_users";
public static final String EMAIL_CLAIM = "email";
public static final String PREFERRED_USERNAME_CLAIM = "preferred_username";
public static final String GIVEN_NAME_CLAIM = "given_name";


private static final boolean CREATE_USERS = Boolean.parseBoolean(System.getProperty(CREATE_USERS_KEY,"true"));

private JwtParser jwtParser = null;
private OpenIDConfig config = null;

Expand Down Expand Up @@ -69,11 +78,22 @@ public void manage(Handler handler, @NotNull Context ctx, @NotNull Set<RouteRole
private DataApiPrincipal getUserFromToken(Context ctx) throws CwmsAuthException {
try {
Jws<Claims> token = jwtParser.parseClaimsJws(getToken(ctx));
String username = token.getBody().get("preferred_username",String.class);
AuthDao dao = AuthDao.getInstance(JooqDao.getDslContext(ctx),ctx.attribute(ApiServlet.OFFICE_ID));
String edipiStr = username.substring(username.lastIndexOf(".") + 1);
long edipi = Long.parseLong(edipiStr);
return dao.getPrincipalFromEdipi(edipi);
Claims claims = token.getBody();
final String issuer = claims.getIssuer();
final String subject = claims.getSubject();
final String oidcPrincipal = issuer + "::" + subject;
AuthDao dao = AuthDao.getInstance(JooqDao.getDslContext(ctx), ctx.attribute(ApiServlet.OFFICE_ID));
Optional<DataApiPrincipal> principal = dao.getPrincipalFromPrincipal(oidcPrincipal);
if (principal.isPresent()) {
return principal.get();
} else if (CREATE_USERS) {
final String preferredUserName = claims.get(PREFERRED_USERNAME_CLAIM, String.class);
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);
} else {
throw new CwmsAuthException("Not Authorized",HttpServletResponse.SC_UNAUTHORIZED);
}
} catch (NumberFormatException | JwtException ex) {
throw new CwmsAuthException("JWT not valid",ex,HttpServletResponse.SC_UNAUTHORIZED);
}
Expand Down
23 changes: 22 additions & 1 deletion cwms-data-api/src/main/java/cwms/cda/security/OpenIDConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@

public class OpenIDConfig {
private static final FluentLogger log = FluentLogger.forEnclosingClass();
private static final String ALT_WELL_KNOWN = "cwms.dataapi.access.openid.useAltWellKnown";
private static final boolean USE_ALT_WELLKNOWN;

static {
String altWellKnownStr = System.getProperty(ALT_WELL_KNOWN,System.getenv(ALT_WELL_KNOWN));
if (altWellKnownStr != null) {
USE_ALT_WELLKNOWN = Boolean.parseBoolean(altWellKnownStr);
} else {
USE_ALT_WELLKNOWN = false;
}
}

private URL wellKnown;
private URL altWellKnown = null; // silly, but needed by the docker-compose setup so URLs match and work.
private String issuer;
private URL authUrl;
private URL tokenUrl;
Expand All @@ -31,6 +44,10 @@ public class OpenIDConfig {

public OpenIDConfig(URL wellKnown, String altAuthUrl) throws IOException {
this.wellKnown = wellKnown;
if (USE_ALT_WELLKNOWN) {
this.altWellKnown = substituteBase(wellKnown, altAuthUrl);
}

HttpURLConnection http = null;
try
{
Expand Down Expand Up @@ -96,8 +113,12 @@ public URL getJwksUrl() {
}

public SecurityScheme getScheme() {
URL theUrl = wellKnown;
if (USE_ALT_WELLKNOWN) {
theUrl = altWellKnown;
}
return new SecurityScheme().type(Type.OPENIDCONNECT)
.openIdConnectUrl(wellKnown.toString())
.openIdConnectUrl(theUrl.toString())
.name("Authorization")
.flows(flows)
.in(In.HEADER);
Expand Down
Loading
Loading