Skip to content

Commit 15ee22f

Browse files
authored
Feature/354 openid auth (#1007)
Adds support for initial user creation from a trusted JWT. NOTES: - Requires "execute on cwms_upass" for the web_user role. - Still some work to be done on mapping claims, some will be ignored but the test keycloak just doesn't have several configured at the moment. Uses the "principle_name" (note: spelled incorrectly in database) field to store the combination of JWT issuer and subject claim . The subject is a UUID and so the combination of issue+subject is always unique. This was done after discovering that the one of the test infrastructure OIDC providers doesn't include the CAC EDIPI anywhere and it could not be used for user lookup. Using a full identity provider principal is better anyways, so may as well start here. Additionally updates the docker-compose to require no external setup. This should make testing and development far easier for newcomers to the project.
1 parent c224cc5 commit 15ee22f

File tree

18 files changed

+474
-146
lines changed

18 files changed

+474
-146
lines changed

Dockerfile

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
FROM gradle:8.5-jdk8 as builder
1+
FROM gradle:8.5-jdk8 AS builder
22
USER $USER
33
RUN --mount=type=cache,target=/home/gradle/.gradle
44
WORKDIR /builddir
55
COPY . /builddir/
6-
RUN gradle clean prepareDockerBuild --info --no-daemon
6+
RUN gradle prepareDockerBuild --info --no-daemon
77

8-
FROM alpine:3.21.0 as tomcat_base
8+
FROM alpine:3.21.0 AS tomcat_base
99
RUN apk --no-cache upgrade && \
1010
apk --no-cache add \
1111
openjdk8-jre \
@@ -41,10 +41,10 @@ ENV CDA_POOL_MAX_ACTIVE "30"
4141
ENV CDA_POOL_MAX_IDLE "10"
4242
ENV CDA_POOL_MIN_IDLE "5"
4343
ENV cwms.dataapi.access.providers "KeyAccessManager,OpenID"
44-
ENV cwms.dataapi.access.openid.wellKnownUrl "https://identity-test.cwbi.us/auth/realms/cwbi/.well-known/openid-configuration"
45-
ENV cwms.dataapi.access.openid.issuer "https://identity-test.cwbi.us/auth/realms/cwbi"
44+
ENV cwms.dataapi.access.openid.wellKnownUrl "https://<prefix>/.well-known/openid-configuration"
45+
ENV cwms.dataapi.access.openid.issuer "<issuer>"
4646
ENV cwms.dataapi.access.openid.timeout "604800"
47-
ENV cwms.dataapi.access.openid.altAuthUrl "https://identityc-test.cwbi.us/auth/realms/cwbi"
47+
#ENV cwms.dataapi.access.openid.altAuthUrl "https://identityc-test.cwbi.us/auth/realms/cwbi"
4848

4949
# used to simplify redeploy in certain contexts. Update to match -<marker> in image label
5050
ENV IMAGE_MARKER="a"

compose_files/keycloak/realm.json

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"oauth2DeviceCodeLifespan": 600,
2727
"oauth2DevicePollingInterval": 5,
2828
"enabled": true,
29-
"sslRequired": "external",
29+
"sslRequired": "none",
3030
"registrationAllowed": false,
3131
"registrationEmailAsUsername": false,
3232
"rememberMe": false,
@@ -662,7 +662,8 @@
662662
"alwaysDisplayInConsole": true,
663663
"clientAuthenticatorType": "client-secret",
664664
"redirectUris": [
665-
"cwms-data.test"
665+
"https://cwms-data.test:8444/*",
666+
"https://localhost:5010/*"
666667
],
667668
"webOrigins": [
668669
"*"
@@ -671,7 +672,7 @@
671672
"bearerOnly": false,
672673
"consentRequired": false,
673674
"standardFlowEnabled": true,
674-
"implicitFlowEnabled": false,
675+
"implicitFlowEnabled": true,
675676
"directAccessGrantsEnabled": true,
676677
"serviceAccountsEnabled": false,
677678
"publicClient": true,
@@ -2291,6 +2292,8 @@
22912292
{
22922293
"username": "m5hectest",
22932294
"enabled": true,
2295+
"email": "noreply@data.test",
2296+
"emailVerified": true,
22942297
"credentials": [
22952298
{
22962299
"type": "password",
@@ -2300,6 +2303,22 @@
23002303
"realmRoles": [
23012304
"cwms_user"
23022305
]
2306+
},
2307+
{
2308+
"username": "q0hecoidc",
2309+
"enabled": true,
2310+
"email": "noreply-oidc@data.test",
2311+
"emailVerified": true,
2312+
"credentials": [
2313+
{
2314+
"type": "password",
2315+
"value": "q0hecoidc"
2316+
}
2317+
],
2318+
"realmRoles": [
2319+
"cwms_user",
2320+
"new_user"
2321+
]
23032322
}
23042323
]
23052324
}

compose_files/sql/users.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
set define on
22
define OFFICE_EROC=&1
3-
begin
4-
3+
begin
54
cwms_sec.add_user_to_group('&&OFFICE_EROC.webtest','All Users', 'HQ');
65
cwms_sec.add_user_to_group('&&OFFICE_EROC.webtest','All Users', 'SPK');
76
cwms_sec.add_user_to_group('&&OFFICE_EROC.webtest','CWMS Users', 'HQ');
@@ -21,6 +20,7 @@ begin
2120
cwms_sec.add_cwms_user('m5hectest',NULL,'SWT');
2221
cwms_sec.add_user_to_group('m5hectest','All Users', 'SWT');
2322
cwms_sec.add_user_to_group('m5hectest','CWMS Users', 'SWT');
23+
execute immediate 'grant excecute on cwms_upass to web_user';
2424
end;
2525
/
2626
quit;

compose_files/tomcat/logging.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ org.apache.catalina.util.LifecycleBase.handlers = java.util.logging.ConsoleHandl
4242

4343
org.apache.tomcat.jdbc.level = INFO
4444
org.apache.tomcat.jdbc.handlers = java.util.logging.ConsoleHandler
45+
cwms.cda.security.level = FINE

compose_files/traefik/traefik.yml

Lines changed: 0 additions & 31 deletions
This file was deleted.

cwms-data-api/build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ dependencies {
149149
testImplementation(libs.cwms.tomcat.auth)
150150
testImplementation(libs.apache.freemarker)
151151

152+
testRuntimeOnly("org.slf4j:slf4j-jdk14:2.0.16")
153+
152154
webjars(libs.swagger.ui) {
153155
transitive = false
154156
}

cwms-data-api/src/main/java/cwms/cda/ApiServlet.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ public void init() {
457457
})
458458
.routes(this::configureRoutes)
459459
.javalinServlet();
460+
logger.atInfo().log("Javalin initialized.");
460461
}
461462

462463
private String obtainFullVersion(ServletConfig servletConfig) throws ServletException {
@@ -479,7 +480,6 @@ private CdaAccessManager buildAccessManager(String provider) {
479480
} catch (ServiceNotFoundException err) {
480481
throw new RuntimeException("Unable to initialize access manager",err);
481482
}
482-
483483
}
484484

485485
protected void configureRoutes() {

cwms-data-api/src/main/java/cwms/cda/data/dao/AuthDao.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,16 @@
1717
import io.javalin.core.security.RouteRole;
1818
import io.javalin.http.Context;
1919
import io.javalin.http.HttpCode;
20+
import usace.cwms.db.jooq.codegen.packages.cwms_sec.UPDATE_USER_DATA;
21+
2022
import java.security.NoSuchAlgorithmException;
2123
import java.security.SecureRandom;
2224
import java.sql.Connection;
2325
import java.sql.Date;
2426
import java.sql.PreparedStatement;
2527
import java.sql.ResultSet;
2628
import java.sql.SQLException;
29+
import java.sql.Types;
2730
import java.time.ZoneId;
2831
import java.time.ZonedDateTime;
2932
import java.util.ArrayList;
@@ -66,6 +69,13 @@ public class AuthDao extends Dao<DataApiPrincipal> {
6669
private static final String USER_FOR_EDIPI =
6770
"select userid from cwms_20.at_sec_cwms_users where edipi = ?";
6871

72+
// NOTE: the column name *should* be principal_name. It was spelled incorrectly and never changed for the life of the schema.
73+
private static final String USER_EXISTS =
74+
"select userid from cwms_20.at_sec_cwms_users where principle_name = ?";
75+
76+
private static final String ADD_CWMS_USER = "CALL cwms_20.cwms_sec.create_user(?,?,?,?)";
77+
private static final String UPDATE_INFO = "CALL cwms_20.cwms_upass.update_user_data(?,?,null,null,null,?,?)";
78+
6979
public static final String CREATE_API_KEY = "insert into cwms_20.at_api_keys"
7080
+ "(userid, key_name, apikey, created, expires) values(UPPER(?),?,?,?,?)";
7181
public static final String REMOVE_API_KEY = "delete from cwms_20.at_api_keys "
@@ -239,6 +249,27 @@ private String userForEdipi(long edipi) throws CwmsAuthException {
239249
}
240250
}
241251

252+
private String userForPrincipal(String principal) throws CwmsAuthException {
253+
try {
254+
return dsl.connectionResult(c -> {
255+
setSessionForAuthCheck(c);
256+
try (PreparedStatement userForEdipi = c.prepareStatement(USER_EXISTS)) {
257+
userForEdipi.setString(1, principal);
258+
try (ResultSet rs = userForEdipi.executeQuery()) {
259+
if (rs.next()) {
260+
return rs.getString(1);
261+
} else {
262+
return null;
263+
}
264+
}
265+
}
266+
});
267+
} catch (DataAccessException ex) {
268+
logger.atInfo().withCause(ex).log("Unable to lookup user.");
269+
throw new CwmsAuthException("Unable to lookup user.", ex);
270+
}
271+
}
272+
242273
/**
243274
* Build a DataApiPrincipal from a given EDIPI value.
244275
* @param edipi the Edipi value to look up.
@@ -489,4 +520,58 @@ public DataApiPrincipal getDataApiPrincipal(Context ctx) {
489520
public void resetContext(DSLContext dslContext) {
490521
this.dsl = dslContext;
491522
}
523+
524+
/**
525+
* Returns a principal from user if that user exists. otherwise empty optional
526+
*
527+
* Uses the "principle" column directly with the provided value as-is.
528+
*
529+
* @param principal provider + subject principal to lookup.
530+
* @return
531+
* @throws CwmsAuthException if anything goes wrong with the database query.
532+
*/
533+
public Optional<DataApiPrincipal> getPrincipalFromPrincipal(String principal) throws CwmsAuthException {
534+
String user = userForPrincipal(principal);
535+
if (user != null) {
536+
Set<RouteRole> roles = this.getRolesForUser(user);
537+
// In this case "cac_auth" just means the user is an actually user verify by some sort of
538+
// identify management system. E.g. "not an apikey"
539+
roles.add(new Role("cac_auth"));
540+
return Optional.of(new DataApiPrincipal(user, roles));
541+
} else {
542+
return Optional.empty();
543+
}
544+
}
545+
546+
547+
public DataApiPrincipal createUser(String username, String principal, String fullname, String email) throws CwmsAuthException {
548+
try {
549+
dsl.connection(c -> {
550+
setSessionForAuthCheck(c);
551+
try (PreparedStatement createUser = c.prepareStatement(ADD_CWMS_USER);
552+
PreparedStatement updateData = c.prepareStatement(UPDATE_INFO)) {
553+
createUser.setString(1, username);
554+
createUser.setNull(2, Types.VARCHAR);
555+
createUser.setNull(3, Types.ARRAY, "CWMS_T_CHAR_32_ARRAY");
556+
createUser.setNull(4, Types.VARCHAR);
557+
createUser.execute();
558+
559+
updateData.setString(1, username);
560+
updateData.setString(2,fullname);
561+
updateData.setString(3, email);
562+
updateData.setString(4, principal);
563+
updateData.execute();
564+
}
565+
});
566+
Optional<DataApiPrincipal> apiPrincipal = getPrincipalFromPrincipal(principal);
567+
if (apiPrincipal.isPresent()) {
568+
return apiPrincipal.get();
569+
} else {
570+
throw new CwmsAuthException("User " + username + " was created, however no principal object could be created.");
571+
}
572+
} catch (DataAccessException ex) {
573+
logger.atInfo().withCause(ex).log("Unable to create user " + username);
574+
throw new CwmsAuthException("Unable to create user " + username, ex);
575+
}
576+
}
492577
}

cwms-data-api/src/main/java/cwms/cda/security/OpenIDAccessManager.java

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import java.util.Base64.Decoder;
3333
import java.util.HashMap;
3434
import java.util.Map;
35+
import java.util.Optional;
3536
import java.util.Set;
3637
import javax.servlet.http.HttpServletResponse;
3738
import org.jetbrains.annotations.NotNull;
@@ -42,6 +43,14 @@
4243
public class OpenIDAccessManager extends CdaAccessManager {
4344
private static final FluentLogger log = FluentLogger.forEnclosingClass();
4445
public static final String AUTHORIZATION = "Authorization";
46+
public static final String CREATE_USERS_KEY = "cwms.dataapi.access.openid.create_users";
47+
public static final String EMAIL_CLAIM = "email";
48+
public static final String PREFERRED_USERNAME_CLAIM = "preferred_username";
49+
public static final String GIVEN_NAME_CLAIM = "given_name";
50+
51+
52+
private static final boolean CREATE_USERS = Boolean.parseBoolean(System.getProperty(CREATE_USERS_KEY,"true"));
53+
4554
private JwtParser jwtParser = null;
4655
private OpenIDConfig config = null;
4756

@@ -69,11 +78,22 @@ public void manage(Handler handler, @NotNull Context ctx, @NotNull Set<RouteRole
6978
private DataApiPrincipal getUserFromToken(Context ctx) throws CwmsAuthException {
7079
try {
7180
Jws<Claims> token = jwtParser.parseClaimsJws(getToken(ctx));
72-
String username = token.getBody().get("preferred_username",String.class);
73-
AuthDao dao = AuthDao.getInstance(JooqDao.getDslContext(ctx),ctx.attribute(ApiServlet.OFFICE_ID));
74-
String edipiStr = username.substring(username.lastIndexOf(".") + 1);
75-
long edipi = Long.parseLong(edipiStr);
76-
return dao.getPrincipalFromEdipi(edipi);
81+
Claims claims = token.getBody();
82+
final String issuer = claims.getIssuer();
83+
final String subject = claims.getSubject();
84+
final String oidcPrincipal = issuer + "::" + subject;
85+
AuthDao dao = AuthDao.getInstance(JooqDao.getDslContext(ctx), ctx.attribute(ApiServlet.OFFICE_ID));
86+
Optional<DataApiPrincipal> principal = dao.getPrincipalFromPrincipal(oidcPrincipal);
87+
if (principal.isPresent()) {
88+
return principal.get();
89+
} else if (CREATE_USERS) {
90+
final String preferredUserName = claims.get(PREFERRED_USERNAME_CLAIM, String.class);
91+
final String givenName = claims.get(GIVEN_NAME_CLAIM, String.class);
92+
final String email = claims.get(EMAIL_CLAIM, String.class);
93+
return dao.createUser(preferredUserName, oidcPrincipal, givenName, email);
94+
} else {
95+
throw new CwmsAuthException("Not Authorized",HttpServletResponse.SC_UNAUTHORIZED);
96+
}
7797
} catch (NumberFormatException | JwtException ex) {
7898
throw new CwmsAuthException("JWT not valid",ex,HttpServletResponse.SC_UNAUTHORIZED);
7999
}

cwms-data-api/src/main/java/cwms/cda/security/OpenIDConfig.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,20 @@
1818

1919
public class OpenIDConfig {
2020
private static final FluentLogger log = FluentLogger.forEnclosingClass();
21+
private static final String ALT_WELL_KNOWN = "cwms.dataapi.access.openid.useAltWellKnown";
22+
private static final boolean USE_ALT_WELLKNOWN;
23+
24+
static {
25+
String altWellKnownStr = System.getProperty(ALT_WELL_KNOWN,System.getenv(ALT_WELL_KNOWN));
26+
if (altWellKnownStr != null) {
27+
USE_ALT_WELLKNOWN = Boolean.parseBoolean(altWellKnownStr);
28+
} else {
29+
USE_ALT_WELLKNOWN = false;
30+
}
31+
}
32+
2133
private URL wellKnown;
34+
private URL altWellKnown = null; // silly, but needed by the docker-compose setup so URLs match and work.
2235
private String issuer;
2336
private URL authUrl;
2437
private URL tokenUrl;
@@ -31,6 +44,10 @@ public class OpenIDConfig {
3144

3245
public OpenIDConfig(URL wellKnown, String altAuthUrl) throws IOException {
3346
this.wellKnown = wellKnown;
47+
if (USE_ALT_WELLKNOWN) {
48+
this.altWellKnown = substituteBase(wellKnown, altAuthUrl);
49+
}
50+
3451
HttpURLConnection http = null;
3552
try
3653
{
@@ -96,8 +113,12 @@ public URL getJwksUrl() {
96113
}
97114

98115
public SecurityScheme getScheme() {
116+
URL theUrl = wellKnown;
117+
if (USE_ALT_WELLKNOWN) {
118+
theUrl = altWellKnown;
119+
}
99120
return new SecurityScheme().type(Type.OPENIDCONNECT)
100-
.openIdConnectUrl(wellKnown.toString())
121+
.openIdConnectUrl(theUrl.toString())
101122
.name("Authorization")
102123
.flows(flows)
103124
.in(In.HEADER);

0 commit comments

Comments
 (0)