Skip to content

Commit 56bdcde

Browse files
authored
feat: admin-only /api/users/twoFactor audit endpoints [DHIS2-20097] (#23925) (#24248)
1 parent 933e947 commit 56bdcde

3 files changed

Lines changed: 561 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*
2+
* Copyright (c) 2004-2026, University of Oslo
3+
* All rights reserved.
4+
*
5+
* Redistribution and use in source and binary forms, with or without
6+
* modification, are permitted provided that the following conditions are met:
7+
*
8+
* 1. Redistributions of source code must retain the above copyright notice, this
9+
* list of conditions and the following disclaimer.
10+
*
11+
* 2. Redistributions in binary form must reproduce the above copyright notice,
12+
* this list of conditions and the following disclaimer in the documentation
13+
* and/or other materials provided with the distribution.
14+
*
15+
* 3. Neither the name of the copyright holder nor the names of its contributors
16+
* may be used to endorse or promote products derived from this software without
17+
* specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22+
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
23+
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
24+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
25+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
26+
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
package org.hisp.dhis.security.twofa.audit;
31+
32+
import java.util.ArrayList;
33+
import java.util.Date;
34+
import java.util.EnumMap;
35+
import java.util.List;
36+
import java.util.Map;
37+
import javax.annotation.CheckForNull;
38+
import lombok.RequiredArgsConstructor;
39+
import org.hisp.dhis.security.twofa.TwoFactorType;
40+
import org.springframework.jdbc.core.JdbcTemplate;
41+
import org.springframework.stereotype.Service;
42+
43+
/**
44+
* Native-SQL backed provider for the 2FA enrolment audit endpoints. Aggregates counts and lists
45+
* users directly against the {@code userinfo} / {@code userrolemembers} / {@code
46+
* userroleauthorities} tables, avoiding full-graph hydration of {@code User} entities and their
47+
* lazy {@code userRoles} collections.
48+
*
49+
* @author Morten Svanaes
50+
*/
51+
@Service
52+
@RequiredArgsConstructor
53+
public class TwoFactorAuditQueryService {
54+
55+
public enum TwoFactorAuditStatus {
56+
ALL,
57+
ENABLED,
58+
DISABLED
59+
}
60+
61+
private static final String ENABLED_TYPES_SQL_LIST = "('TOTP_ENABLED','EMAIL_ENABLED')";
62+
63+
private static final String EFFECTIVE_TYPE_SQL = "COALESCE(twofactortype, 'NOT_ENABLED')";
64+
65+
// Only count accounts that can actually log in.
66+
private static final String ACTIVE_ACCOUNT_FILTER =
67+
" AND disabled = false AND invitation = false";
68+
69+
private final JdbcTemplate jdbcTemplate;
70+
71+
/** Returns the row count of active users grouped by {@code twofactortype}. */
72+
public Map<TwoFactorType, Long> countByType() {
73+
Map<TwoFactorType, Long> result = new EnumMap<>(TwoFactorType.class);
74+
for (TwoFactorType type : TwoFactorType.values()) {
75+
result.put(type, 0L);
76+
}
77+
jdbcTemplate.query(
78+
"SELECT "
79+
+ EFFECTIVE_TYPE_SQL
80+
+ " AS effective_type, COUNT(*) FROM userinfo WHERE 1=1"
81+
+ ACTIVE_ACCOUNT_FILTER
82+
+ " GROUP BY "
83+
+ EFFECTIVE_TYPE_SQL,
84+
rs -> {
85+
String raw = rs.getString(1);
86+
if (raw != null) {
87+
try {
88+
result.put(TwoFactorType.valueOf(raw), rs.getLong(2));
89+
} catch (IllegalArgumentException ignore) {
90+
// Out-of-enum value in the column.
91+
}
92+
}
93+
});
94+
return result;
95+
}
96+
97+
/**
98+
* Returns the count of active users holding the {@code ALL} authority and how many of them have
99+
* no active 2FA.
100+
*/
101+
public PrivilegedCounts countPrivileged() {
102+
String sql =
103+
"SELECT COUNT(DISTINCT urm.userid) AS with_all,"
104+
+ " COUNT(DISTINCT urm.userid) FILTER ("
105+
+ " WHERE COALESCE(u.twofactortype, 'NOT_ENABLED') NOT IN "
106+
+ ENABLED_TYPES_SQL_LIST
107+
+ " ) AS with_all_missing"
108+
+ " FROM userrolemembers urm"
109+
+ " JOIN userroleauthorities ura ON ura.userroleid = urm.userroleid"
110+
+ " JOIN userinfo u ON u.userinfoid = urm.userid"
111+
+ " WHERE ura.authority = 'ALL'"
112+
+ " AND u.disabled = false AND u.invitation = false";
113+
PrivilegedCounts counts =
114+
jdbcTemplate.queryForObject(
115+
sql, (rs, n) -> new PrivilegedCounts(rs.getLong(1), rs.getLong(2)));
116+
return counts == null ? new PrivilegedCounts(0L, 0L) : counts;
117+
}
118+
119+
/** Returns the number of active users matching the given filter. */
120+
public int count(TwoFactorAuditStatus status, @CheckForNull List<TwoFactorType> types) {
121+
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM userinfo WHERE 1=1");
122+
sql.append(ACTIVE_ACCOUNT_FILTER);
123+
List<Object> params = new ArrayList<>();
124+
appendStatusClause(sql, status);
125+
appendTypeClause(sql, params, types);
126+
Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray());
127+
return count == null ? 0 : count;
128+
}
129+
130+
/**
131+
* Returns the matching active user rows projected to the audit-row shape. {@code offset}/{@code
132+
* limit} are applied DB-side via {@code OFFSET} / {@code LIMIT}; pass {@code limit < 0} to return
133+
* all matches.
134+
*/
135+
public List<UserAuditRow> list(
136+
TwoFactorAuditStatus status, @CheckForNull List<TwoFactorType> types, int offset, int limit) {
137+
StringBuilder sql =
138+
new StringBuilder(
139+
"SELECT uid, username, name, twofactortype, lastlogin,"
140+
+ " email, disabled, invitation FROM userinfo WHERE 1=1");
141+
sql.append(ACTIVE_ACCOUNT_FILTER);
142+
List<Object> params = new ArrayList<>();
143+
appendStatusClause(sql, status);
144+
appendTypeClause(sql, params, types);
145+
sql.append(" ORDER BY username");
146+
if (limit >= 0) {
147+
sql.append(" LIMIT ? OFFSET ?");
148+
params.add(limit);
149+
params.add(Math.max(0, offset));
150+
}
151+
return jdbcTemplate.query(
152+
sql.toString(),
153+
params.toArray(),
154+
(rs, n) ->
155+
new UserAuditRow(
156+
rs.getString("uid"),
157+
rs.getString("username"),
158+
rs.getString("name"),
159+
parseType(rs.getString("twofactortype")),
160+
rs.getTimestamp("lastlogin"),
161+
rs.getString("email"),
162+
rs.getBoolean("disabled"),
163+
rs.getBoolean("invitation")));
164+
}
165+
166+
private static void appendStatusClause(StringBuilder sql, TwoFactorAuditStatus status) {
167+
switch (status) {
168+
case ENABLED ->
169+
sql.append(" AND ")
170+
.append(EFFECTIVE_TYPE_SQL)
171+
.append(" IN ")
172+
.append(ENABLED_TYPES_SQL_LIST);
173+
case DISABLED ->
174+
sql.append(" AND ")
175+
.append(EFFECTIVE_TYPE_SQL)
176+
.append(" NOT IN ")
177+
.append(ENABLED_TYPES_SQL_LIST);
178+
case ALL -> {
179+
// no-op
180+
}
181+
}
182+
}
183+
184+
private static void appendTypeClause(
185+
StringBuilder sql, List<Object> params, @CheckForNull List<TwoFactorType> types) {
186+
if (types == null || types.isEmpty()) return;
187+
sql.append(" AND ").append(EFFECTIVE_TYPE_SQL).append(" IN (");
188+
for (int i = 0; i < types.size(); i++) {
189+
sql.append(i == 0 ? "?" : ",?");
190+
params.add(types.get(i).name());
191+
}
192+
sql.append(")");
193+
}
194+
195+
private static TwoFactorType parseType(@CheckForNull String raw) {
196+
if (raw == null) return TwoFactorType.NOT_ENABLED;
197+
try {
198+
return TwoFactorType.valueOf(raw);
199+
} catch (IllegalArgumentException e) {
200+
return TwoFactorType.NOT_ENABLED;
201+
}
202+
}
203+
204+
public record PrivilegedCounts(long withAllAuthority, long withAllAuthorityMissing2FA) {}
205+
206+
public record UserAuditRow(
207+
String uid,
208+
String username,
209+
String name,
210+
TwoFactorType twoFactorType,
211+
Date lastLogin,
212+
String email,
213+
boolean disabled,
214+
boolean invitation) {}
215+
}

0 commit comments

Comments
 (0)