Skip to content

Commit dc3c11a

Browse files
rootcesmarvin
authored andcommitted
Merge branch 'release/v1.22.0-1'
2 parents 72d7b32 + 311dedf commit dc3c11a

33 files changed

Lines changed: 827 additions & 70 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [v1.22.0-1] - 2026-07-02
10+
- [#234] add multifactor management for admin users
11+
- [#234] enable external ldap configuration
12+
- external ldap allows read access
13+
914
## [v1.21.0-5] - 2026-06-26
1015
### Changed
1116
- [#230] Use the exposition api in kubernetes instead of the ingress api.

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ FROM registry.cloudogu.com/official/java:8.452.09-6
4040
ARG TOMCAT_VERSION
4141

4242
LABEL NAME="official/usermgt" \
43-
VERSION="1.21.0-5" \
43+
VERSION="1.22.0-1" \
4444
maintainer="hello@cloudogu.com"
4545

4646
# mark as webapp for nginx

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Set these to the desired values
22
ARTIFACT_ID=usermgt
33

4-
VERSION=1.21.0-5
4+
VERSION=1.22.0-1
55
# overwrite ADDITIONAL_LDFLAGS to disable static compilation
66
# this should fix https://github.com/golang/go/issues/13470
77
ADDITIONAL_LDFLAGS=""

app/src/main/java/de/triology/universeadm/CasSecurityModule.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ protected void configureRealm() {
9191
addFilterChain("/api/users/import/*", filterConfig(API), filterConfig(ROLES, Roles.ADMINISTRATOR));
9292
addFilterChain("/api/groups", filterConfig(API), filterConfig(ROLES, Roles.ADMINISTRATOR));
9393
addFilterChain("/api/groups/*", filterConfig(API), filterConfig(ROLES, Roles.ADMINISTRATOR));
94+
addFilterChain("/api/mfa/*", filterConfig(API), filterConfig(ROLES, Roles.ADMINISTRATOR));
9495
addFilterChain("/api/account", filterConfig(API));
9596
addFilterChain("/api/account/*", filterConfig(API));
9697
addFilterChain("/**", filterConfig(AUTHC));

app/src/main/java/de/triology/universeadm/MainModule.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import de.triology.universeadm.configuration.ApplicationConfiguration;
1010
import de.triology.universeadm.configuration.I18nConfiguration;
1111
import de.triology.universeadm.configuration.MailConfiguration;
12+
import de.triology.universeadm.multifactor.MultifactorResource;
1213
import org.slf4j.Logger;
1314
import org.slf4j.LoggerFactory;
1415

@@ -76,6 +77,7 @@ protected void configureServlets() {
7677
bind(CatchAllExceptionMapper.class);
7778
bind(SubjectResource.class);
7879
bind(LogoutResource.class);
80+
bind(MultifactorResource.class);
7981

8082
// filter
8183
filter("/*").through(LDAPConnectionStrategyBindFilter.class);

app/src/main/java/de/triology/universeadm/mapping/MappingHandler.java

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,22 @@ public PaginationResult<T> query(PaginationQuery query) {
228228
private PaginationResult<T> doQuery(PaginationQuery query) throws LDAPException {
229229
final List<T> entities = Lists.newArrayList();
230230

231+
// Check if VLV is supported, otherwise fall back to simple paged results
232+
try {
233+
return doQueryWithVLV(query);
234+
} catch (LDAPException ex) {
235+
// If VLV fails with "critical extension is not recognized", fall back to simple pagination
236+
if (ex.getMessage() != null && ex.getMessage().contains("critical extension is not recognized")) {
237+
logger.warn("VLV not supported by LDAP server, falling back to simple paged results");
238+
return doQueryWithSimplePaging(query);
239+
}
240+
throw ex;
241+
}
242+
}
243+
244+
private PaginationResult<T> doQueryWithVLV(PaginationQuery query) throws LDAPException {
245+
final List<T> entities = Lists.newArrayList();
246+
231247
int vlvOffset = query.getOffset() + 1;
232248
int vlvLimit = query.getPageSize() - 1;
233249
int vlvContentCount = 0;
@@ -250,6 +266,57 @@ private PaginationResult<T> doQuery(PaginationQuery query) throws LDAPException
250266
return new PaginationResult<>(entities, vlvContentCount, encodeContextId(vlvContextID));
251267
}
252268

269+
private PaginationResult<T> doQueryWithSimplePaging(PaginationQuery query) throws LDAPException {
270+
final List<T> allEntities = Lists.newArrayList();
271+
272+
SearchRequest searchRequest = new SearchRequest(
273+
mapper.getParentDN(),
274+
SearchScope.SUB,
275+
createFilter(query.getQuery(), query.getExcludes()),
276+
returningAttributes
277+
);
278+
279+
// Use simple paged results to get all entries
280+
ASN1OctetString resumeCookie = null;
281+
LDAPInterface connection = strategy.get();
282+
283+
while (true) {
284+
searchRequest.setControls(new SimplePagedResultsControl(100, resumeCookie, false));
285+
SearchResult searchResult = connection.search(searchRequest);
286+
287+
for (SearchResultEntry e : searchResult.getSearchEntries()) {
288+
T object = consume(e, mapper.convert(e));
289+
if (object != null) {
290+
allEntities.add(object);
291+
}
292+
}
293+
294+
SimplePagedResultsControl responseControl = SimplePagedResultsControl.get(searchResult);
295+
if (responseControl != null && responseControl.moreResultsToReturn()) {
296+
resumeCookie = responseControl.getCookie();
297+
} else {
298+
break;
299+
}
300+
}
301+
302+
// Sort the results manually
303+
Collections.sort(allEntities);
304+
if (query.isReverse()) {
305+
Collections.reverse(allEntities);
306+
}
307+
308+
// Apply pagination manually
309+
int totalCount = allEntities.size();
310+
int fromIndex = Math.min(query.getOffset(), totalCount);
311+
int toIndex = Math.min(fromIndex + query.getPageSize(), totalCount);
312+
313+
List<T> pageEntities = (fromIndex < totalCount)
314+
? allEntities.subList(fromIndex, toIndex)
315+
: Collections.emptyList();
316+
317+
return new PaginationResult<>(pageEntities, totalCount, null);
318+
}
319+
253320
private ServerSideSortRequestControl createSortControl(PaginationQuery query) {
254321
String sortAttribute = "cn";
255322
MappingAttribute attribute = mapper.getAttribute(query.getSortBy());
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package de.triology.universeadm.multifactor;
2+
3+
/**
4+
* Simplified MFA credential for frontend consumption.
5+
*/
6+
public class MfaDTO {
7+
8+
private String username;
9+
private String name;
10+
11+
public MfaDTO() {
12+
}
13+
14+
public MfaDTO(String username, String name) {
15+
this.username = username;
16+
this.name = name;
17+
}
18+
19+
public String getUsername() {
20+
return username;
21+
}
22+
23+
public void setUsername(String username) {
24+
this.username = username;
25+
}
26+
27+
public String getName() {
28+
return name;
29+
}
30+
31+
public void setName(String name) {
32+
this.name = name;
33+
}
34+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package de.triology.universeadm.multifactor;
2+
3+
import com.google.gson.Gson;
4+
import com.google.gson.JsonArray;
5+
import com.google.gson.JsonElement;
6+
import com.google.gson.JsonObject;
7+
import com.google.gson.JsonParseException;
8+
9+
import javax.ws.rs.DELETE;
10+
import javax.ws.rs.GET;
11+
import javax.ws.rs.Path;
12+
import javax.ws.rs.PathParam;
13+
import javax.ws.rs.Produces;
14+
import javax.ws.rs.core.MediaType;
15+
import javax.ws.rs.core.Response;
16+
17+
import org.slf4j.Logger;
18+
import org.slf4j.LoggerFactory;
19+
20+
import java.io.BufferedReader;
21+
import java.io.IOException;
22+
import java.io.InputStreamReader;
23+
import java.net.HttpURLConnection;
24+
import java.net.URL;
25+
import java.nio.charset.StandardCharsets;
26+
import java.util.ArrayList;
27+
import java.util.Base64;
28+
import java.util.List;
29+
30+
@Path("/mfa")
31+
@Produces(MediaType.APPLICATION_JSON)
32+
public class MultifactorResource {
33+
34+
private static final Logger LOG = LoggerFactory.getLogger(MultifactorResource.class);
35+
private static final Gson GSON = new Gson();
36+
private static final String FQDN = System.getProperty("cas.mfa.fqdn");
37+
private static final String user = System.getProperty("cas.mfa.user");
38+
private static final String password = System.getProperty("cas.mfa.password");
39+
private static final String CAS_MFA_ENDPOINT = "https://" + FQDN + "/cas/actuator/gauthCredentialRepository";
40+
41+
public MultifactorResource() {
42+
}
43+
44+
@GET
45+
@Path("/{username}")
46+
public Response getMfa(@PathParam("username") String username) {
47+
try {
48+
String jsonResponse = callCasMfaGetApi(username);
49+
MfaDTO credentials = parse(jsonResponse);
50+
return Response.ok(credentials, MediaType.APPLICATION_JSON).build();
51+
} catch (IOException e) {
52+
LOG.error("Failed to load MFA data from CAS", e);
53+
return Response.status(Response.Status.BAD_GATEWAY)
54+
.entity("{\"message\":\"Failed to load MFA data from CAS\"}")
55+
.type(MediaType.APPLICATION_JSON)
56+
.build();
57+
}
58+
}
59+
60+
@DELETE
61+
@Path("/{username}")
62+
public Response deleteMfa(@PathParam("username") String username) {
63+
try {
64+
callCasMfaDeleteApi(username);
65+
return Response.noContent().build();
66+
} catch (IOException e) {
67+
LOG.error("Failed to delete MFA credentials for user: {}", username, e);
68+
return Response.status(Response.Status.BAD_GATEWAY)
69+
.entity("{\"message\":\"Failed to delete MFA credentials\"}")
70+
.type(MediaType.APPLICATION_JSON)
71+
.build();
72+
}
73+
}
74+
75+
/**
76+
* Parses the JSON response from the CAS API and extracts only username and (device-)name.
77+
*/
78+
private MfaDTO parse(String jsonResponse) {
79+
MfaDTO result = new MfaDTO();
80+
81+
try {
82+
JsonArray jsonArray = GSON.fromJson(jsonResponse, JsonArray.class);
83+
84+
for (JsonElement element : jsonArray) {
85+
JsonObject obj = element.getAsJsonObject();
86+
87+
String username = obj.has("username") ? obj.get("username").getAsString() : null;
88+
String name = obj.has("name") ? obj.get("name").getAsString() : null;
89+
90+
result.setUsername(username);
91+
result.setName(name);
92+
}
93+
} catch (JsonParseException | IllegalStateException e) {
94+
LOG.error("Failed to parse MFA credentials JSON", e);
95+
}
96+
97+
return result;
98+
}
99+
100+
/**
101+
* Calls the CAS MFA API to list all MFA credentials.
102+
*/
103+
private String callCasMfaGetApi(String username) throws IOException {
104+
HttpURLConnection connection = null;
105+
try {
106+
URL url = new URL(CAS_MFA_ENDPOINT + "/" + username);
107+
connection = (HttpURLConnection) url.openConnection();
108+
connection.setRequestMethod("GET");
109+
110+
addBasicAuthentication(connection);
111+
112+
int status = connection.getResponseCode();
113+
if (status < 200 || status > 299) {
114+
throw new IOException("CAS MFA endpoint returned status " + status);
115+
}
116+
117+
try (BufferedReader reader =
118+
new BufferedReader(new InputStreamReader(connection.getInputStream(), "UTF-8"))) {
119+
StringBuilder sb = new StringBuilder();
120+
String line;
121+
while ((line = reader.readLine()) != null) {
122+
sb.append(line);
123+
}
124+
return sb.toString();
125+
}
126+
} finally {
127+
if (connection != null) {
128+
connection.disconnect();
129+
}
130+
}
131+
}
132+
133+
/**
134+
* Calls the CAS MFA API to delete MFA credentials for a user.
135+
*
136+
* @param username username of the user whose MFA credentials should be deleted
137+
*/
138+
private void callCasMfaDeleteApi(String username) throws IOException {
139+
HttpURLConnection connection = null;
140+
try {
141+
String urlString = CAS_MFA_ENDPOINT + "/" + username;
142+
URL url = new URL(urlString);
143+
connection = (HttpURLConnection) url.openConnection();
144+
connection.setRequestMethod("DELETE");
145+
146+
addBasicAuthentication(connection);
147+
148+
int status = connection.getResponseCode();
149+
150+
if ((status < 200 || status > 299)) {
151+
throw new IOException("CAS MFA endpoint returned status " + status);
152+
}
153+
} finally {
154+
if (connection != null) {
155+
connection.disconnect();
156+
}
157+
}
158+
}
159+
160+
/**
161+
* Adds Basic Authentication Header to the HTTP connection.
162+
*
163+
* @param connection die HTTP-Verbindung
164+
*/
165+
private void addBasicAuthentication(HttpURLConnection connection) {
166+
if (user != null && password != null) {
167+
String basicAuth = Base64.getEncoder()
168+
.encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8));
169+
connection.setRequestProperty("Authorization", "Basic " + basicAuth);
170+
}
171+
}
172+
173+
private HttpURLConnection createConnection(String username, String method) throws IOException {
174+
HttpURLConnection connection = null;
175+
String urlString = CAS_MFA_ENDPOINT + "/" + username;
176+
URL url = new URL(urlString);
177+
connection = (HttpURLConnection) url.openConnection();
178+
connection.setRequestMethod(method);
179+
180+
addBasicAuthentication(connection);
181+
182+
return connection;
183+
}
184+
}

app/src/main/ui/src/components/contexts/ApplicationContext.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
import { createContext, useContext } from "react";
22
import type { CasUser } from "../../services/CasUser";
33

4+
const isExternalLdap = process.env.EXTERNAL_LDAP || "false";
5+
6+
47
export type ApplicationContextProps = {
58
casUser: CasUser;
9+
externalLdap: boolean;
610
}
711
export const ApplicationContext = createContext<ApplicationContextProps>({
812
casUser: {
913
principal: "default",
1014
admin: false,
1115
loading: true
1216
},
17+
externalLdap: isExternalLdap === "true",
1318
});
1419

1520
export function useApplicationContext() {
@@ -20,4 +25,4 @@ export function useApplicationContext() {
2025
}
2126

2227
return context;
23-
}
28+
}

0 commit comments

Comments
 (0)