Skip to content

Commit 9dfc0a7

Browse files
committed
Store MFA authentication status directly on tokens
Move MFA status from being computed from user identities to being stored as a boolean field on tokens themselves. This provides per-token MFA tracking and eliminates the need for complex identity lookups.
1 parent ac606a5 commit 9dfc0a7

File tree

7 files changed

+113
-80
lines changed

7 files changed

+113
-80
lines changed

src/main/java/us/kbase/auth2/lib/Authentication.java

Lines changed: 19 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -745,13 +745,19 @@ public void forceResetAllPasswords(final IncomingToken token)
745745

746746
private NewToken login(final UserName userName, final TokenCreationContext tokenCtx)
747747
throws AuthStorageException {
748+
return login(userName, tokenCtx, false);
749+
}
750+
751+
private NewToken login(final UserName userName, final TokenCreationContext tokenCtx,
752+
final Boolean mfaAuthenticated) throws AuthStorageException {
748753
final NewToken nt = new NewToken(StoredToken.getBuilder(
749-
TokenType.LOGIN, randGen.randomUUID(), userName)
750-
.withLifeTime(clock.instant(),
751-
cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.LOGIN))
752-
.withContext(tokenCtx)
753-
.build(),
754-
randGen.getToken());
754+
TokenType.LOGIN, randGen.randomUUID(), userName)
755+
.withLifeTime(clock.instant(),
756+
cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.LOGIN))
757+
.withContext(tokenCtx)
758+
.withMfaAuthenticated(mfaAuthenticated)
759+
.build(),
760+
randGen.getToken());
755761
storage.storeToken(nt.getStoredToken(), nt.getTokenHash());
756762
setLastLogin(userName);
757763
logInfo("Logged in user {} with token {}",
@@ -905,7 +911,9 @@ public NewToken createToken(
905911
final NewToken nt = new NewToken(StoredToken.getBuilder(tokenType, id, au.getUserName())
906912
.withLifeTime(clock.instant(), life)
907913
.withContext(tokenCtx)
908-
.withTokenName(tokenName).build(),
914+
.withTokenName(tokenName)
915+
.withMfaAuthenticated(null) // Agent/Dev/Serv tokens don't have MFA status
916+
.build(),
909917
randGen.getToken());
910918
storage.storeToken(nt.getStoredToken(), nt.getTokenHash());
911919
logInfo("User {} created {} token {}", au.getUserName().getName(), tokenType, id);
@@ -2043,6 +2051,7 @@ public NewToken testModeCreateToken(
20432051
final NewToken nt = new NewToken(StoredToken.getBuilder(tokenType, id, userName)
20442052
.withLifeTime(clock.instant(), TEST_MODE_DATA_LIFETIME_MS)
20452053
.withNullableTokenName(tokenName)
2054+
.withMfaAuthenticated(null) // Test mode tokens don't have MFA status
20462055
.build(),
20472056
randGen.getToken());
20482057
storage.testModeStoreToken(nt.getStoredToken(), nt.getTokenHash());
@@ -2339,7 +2348,9 @@ public NewToken login(
23392348
linked, u.get().getUserName().getName());
23402349
}
23412350
}
2342-
return login(u.get().getUserName(), tokenCtx);
2351+
final Boolean mfaStatus = ri.get().getDetails() != null ?
2352+
ri.get().getDetails().getMfaAuthenticated() : null;
2353+
return login(u.get().getUserName(), tokenCtx, mfaStatus);
23432354
}
23442355

23452356
private Optional<RemoteIdentity> getIdentity(
@@ -3142,52 +3153,6 @@ public long getSuggestedTokenCacheTime() throws AuthStorageException {
31423153
return cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.EXT_CACHE);
31433154
}
31443155

3145-
/** Get MFA status for a token by checking the token user's identities.
3146-
* @param token the token to check.
3147-
* @return true if MFA was used, false if password only, null if unknown or not supported.
3148-
* @throws AuthStorageException if an error occurred accessing the storage system.
3149-
* @throws InvalidTokenException if the token is invalid.
3150-
*/
3151-
public Boolean getMfaStatus(final IncomingToken token)
3152-
throws AuthStorageException, InvalidTokenException {
3153-
if (token == null) {
3154-
return null;
3155-
}
3156-
3157-
// Get the stored token to find the username
3158-
final StoredToken storedToken = getToken(token);
3159-
final UserName userName = storedToken.getUserName();
3160-
3161-
// Check if the user exists before trying to get user details
3162-
try {
3163-
storage.getUser(userName);
3164-
} catch (NoSuchUserException e) {
3165-
// User doesn't exist, return null for MFA status
3166-
return null;
3167-
}
3168-
3169-
try {
3170-
final AuthUser user = getUser(token);
3171-
final Set<us.kbase.auth2.lib.identity.RemoteIdentity> identities = user.getIdentities();
3172-
3173-
// Check for identities with MFA information from supported providers
3174-
if (identities != null) {
3175-
for (final us.kbase.auth2.lib.identity.RemoteIdentity identity : identities) {
3176-
if (identity != null && identity.getDetails() != null) {
3177-
final Boolean mfaStatus = identity.getDetails().getMfaAuthenticated();
3178-
if (mfaStatus != null) {
3179-
return mfaStatus;
3180-
}
3181-
}
3182-
}
3183-
}
3184-
3185-
return null; // No MFA information available
3186-
} catch (DisabledUserException e) {
3187-
// Return null for disabled users
3188-
return null;
3189-
}
3190-
}
31913156

31923157
/** Get the external configuration without providing any credentials.
31933158
*

src/main/java/us/kbase/auth2/lib/storage/mongo/Fields.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ public class Fields {
137137
public static final String TOKEN_CUSTOM_KEY = "k";
138138
/** A value for a custom context key / value pair. */
139139
public static final String TOKEN_CUSTOM_VALUE = "v";
140+
/** Whether the token was created with multi-factor authentication. */
141+
public static final String TOKEN_MFA_AUTHENTICATED = "mfaauth";
140142

141143
/* ************************
142144
* temporary session data fields

src/main/java/us/kbase/auth2/lib/storage/mongo/MongoStorage.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,8 @@ private void storeToken(final String collection, final StoredToken token, final
871871
.append(Fields.TOKEN_DEVICE, ctx.getDevice().orElse(null))
872872
.append(Fields.TOKEN_IP, ctx.getIpAddress().isPresent() ?
873873
ctx.getIpAddress().get().getHostAddress() : null)
874-
.append(Fields.TOKEN_CUSTOM_CONTEXT, toCustomContextList(ctx.getCustomContext()));
874+
.append(Fields.TOKEN_CUSTOM_CONTEXT, toCustomContextList(ctx.getCustomContext()))
875+
.append(Fields.TOKEN_MFA_AUTHENTICATED, token.getMfaAuthenticated());
875876
try {
876877
db.getCollection(collection).insertOne(td);
877878
} catch (MongoWriteException mwe) {
@@ -969,6 +970,7 @@ private StoredToken getToken(final Document t) throws AuthStorageException {
969970
t.getDate(Fields.TOKEN_EXPIRY).toInstant())
970971
.withNullableTokenName(getTokenName(t.getString(Fields.TOKEN_NAME)))
971972
.withContext(toTokenCreationContext(t))
973+
.withMfaAuthenticated(t.getBoolean(Fields.TOKEN_MFA_AUTHENTICATED))
972974
.build();
973975
}
974976

src/main/java/us/kbase/auth2/lib/token/StoredToken.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public class StoredToken {
2323
private final UserName userName;
2424
private final Instant creationDate;
2525
private final Instant expirationDate;
26+
private final Boolean mfaAuthenticated;
2627

2728
private StoredToken(
2829
final UUID id,
@@ -31,7 +32,8 @@ private StoredToken(
3132
final UserName userName,
3233
final TokenCreationContext context,
3334
final Instant creationDate,
34-
final Instant expirationDate) {
35+
final Instant expirationDate,
36+
final Boolean mfaAuthenticated) {
3537
// this stuff is here just in case naughty users use casting to skip a builder step
3638
requireNonNull(creationDate, "created");
3739
// no way to test this one
@@ -43,6 +45,7 @@ private StoredToken(
4345
this.expirationDate = expirationDate;
4446
this.creationDate = creationDate;
4547
this.id = id;
48+
this.mfaAuthenticated = mfaAuthenticated;
4649
}
4750

4851
/** Get the type of the token.
@@ -93,6 +96,13 @@ public Instant getCreationDate() {
9396
public Instant getExpirationDate() {
9497
return expirationDate;
9598
}
99+
100+
/** Get whether the token was created with multi-factor authentication.
101+
* @return true if the token was created with MFA, false if not, null if unknown.
102+
*/
103+
public Boolean getMfaAuthenticated() {
104+
return mfaAuthenticated;
105+
}
96106

97107
@Override
98108
public int hashCode() {
@@ -105,6 +115,7 @@ public int hashCode() {
105115
result = prime * result + ((tokenName == null) ? 0 : tokenName.hashCode());
106116
result = prime * result + ((type == null) ? 0 : type.hashCode());
107117
result = prime * result + ((userName == null) ? 0 : userName.hashCode());
118+
result = prime * result + ((mfaAuthenticated == null) ? 0 : mfaAuthenticated.hashCode());
108119
return result;
109120
}
110121

@@ -165,6 +176,13 @@ public boolean equals(Object obj) {
165176
} else if (!userName.equals(other.userName)) {
166177
return false;
167178
}
179+
if (mfaAuthenticated == null) {
180+
if (other.mfaAuthenticated != null) {
181+
return false;
182+
}
183+
} else if (!mfaAuthenticated.equals(other.mfaAuthenticated)) {
184+
return false;
185+
}
168186
return true;
169187
}
170188

@@ -224,6 +242,12 @@ public interface OptionalsStep {
224242
*/
225243
OptionalsStep withContext(TokenCreationContext context);
226244

245+
/** Specify whether the token was created with multi-factor authentication.
246+
* @param mfaAuthenticated true if MFA was used, false if not, null if unknown.
247+
* @return this builder.
248+
*/
249+
OptionalsStep withMfaAuthenticated(Boolean mfaAuthenticated);
250+
227251
/** Build the token.
228252
* @return a new StoredToken.
229253
*/
@@ -239,6 +263,7 @@ private static class Builder implements LifeStep, OptionalsStep {
239263
private final UserName userName;
240264
private Instant creationDate;
241265
private Instant expirationDate;
266+
private Boolean mfaAuthenticated;
242267

243268
private Builder(final TokenType type, final UUID id, final UserName userName) {
244269
requireNonNull(type, "type");
@@ -269,10 +294,16 @@ public OptionalsStep withContext(final TokenCreationContext context) {
269294
return this;
270295
}
271296

297+
@Override
298+
public OptionalsStep withMfaAuthenticated(final Boolean mfaAuthenticated) {
299+
this.mfaAuthenticated = mfaAuthenticated;
300+
return this;
301+
}
302+
272303
@Override
273304
public StoredToken build() {
274305
return new StoredToken(id, type, tokenName, userName, context,
275-
creationDate, expirationDate);
306+
creationDate, expirationDate, mfaAuthenticated);
276307
}
277308

278309
@Override

src/main/java/us/kbase/auth2/service/api/APIToken.java

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,31 +10,10 @@ public class APIToken extends ExternalToken {
1010
private final long cachefor;
1111
private final Boolean mfaAuthenticated;
1212

13-
/**
14-
* Constructor without MFA status calculation.
15-
* MFA status will be set to null.
16-
*
17-
* @param token the stored token
18-
* @param tokenCacheTimeMillis the token cache time in milliseconds
19-
*/
2013
public APIToken(final StoredToken token, final long tokenCacheTimeMillis) {
2114
super(token);
2215
cachefor = tokenCacheTimeMillis;
23-
mfaAuthenticated = null;
24-
}
25-
26-
/**
27-
* Constructor with MFA status provided.
28-
*
29-
* @param token the stored token
30-
* @param tokenCacheTimeMillis the token cache time in milliseconds
31-
* @param mfaAuthenticated the MFA authentication status
32-
*/
33-
public APIToken(final StoredToken token, final long tokenCacheTimeMillis,
34-
final Boolean mfaAuthenticated) {
35-
super(token);
36-
cachefor = tokenCacheTimeMillis;
37-
this.mfaAuthenticated = mfaAuthenticated;
16+
mfaAuthenticated = token.getMfaAuthenticated();
3817
}
3918

4019
public long getCachefor() {

src/main/java/us/kbase/auth2/service/api/Token.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public APIToken viewToken(@HeaderParam(APIConstants.HEADER_TOKEN) final String t
5858
throws NoTokenProvidedException, InvalidTokenException, AuthStorageException {
5959
final IncomingToken it = getToken(token);
6060
final StoredToken ht = auth.getToken(it);
61-
return new APIToken(ht, auth.getSuggestedTokenCacheTime(), auth.getMfaStatus(it));
61+
return new APIToken(ht, auth.getSuggestedTokenCacheTime());
6262
}
6363

6464
private static class CreateToken extends IncomingJSON {

src/test/java/us/kbase/test/auth2/service/api/TokenEndpointTest.java

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,60 @@ TokenType.AGENT, id, new UserName("foo"))
180180
assertThat("incorrect token", response, is(expected));
181181
}
182182

183+
@Test
184+
public void getTokenMfaIsolation() throws Exception {
185+
// Create user
186+
final UserName userName = new UserName("testuser");
187+
final UUID userUuid = UUID.randomUUID();
188+
manager.storage.createLocalUser(LocalUser.getLocalUserBuilder(
189+
userName, userUuid, new DisplayName("Test User"), inst(10000))
190+
.withEmailAddress(new EmailAddress("[email protected]")).build(),
191+
new PasswordHashAndSalt("password".getBytes(), "salt".getBytes()));
192+
193+
// Create two tokens with different MFA status
194+
final UUID token1Id = UUID.randomUUID();
195+
final UUID token2Id = UUID.randomUUID();
196+
197+
final IncomingToken token1 = new IncomingToken("token1hash");
198+
final IncomingToken token2 = new IncomingToken("token2hash");
199+
200+
// Token 1 with MFA=true
201+
manager.storage.storeToken(StoredToken.getBuilder(
202+
TokenType.LOGIN, token1Id, userName)
203+
.withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(1000000000000000L))
204+
.withMfaAuthenticated(true)
205+
.build(), token1.getHashedToken().getTokenHash());
206+
207+
// Token 2 with MFA=false
208+
manager.storage.storeToken(StoredToken.getBuilder(
209+
TokenType.LOGIN, token2Id, userName)
210+
.withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(1000000000000000L))
211+
.withMfaAuthenticated(false)
212+
.build(), token2.getHashedToken().getTokenHash());
213+
214+
// Test token1 returns MFA=true
215+
final URI target1 = UriBuilder.fromUri(host).path("/api/V2/token").build();
216+
final WebTarget wt1 = CLI.target(target1);
217+
final Builder req1 = wt1.request().header("authorization", token1.getToken());
218+
final Response res1 = req1.get();
219+
220+
assertThat("incorrect response code for token1", res1.getStatus(), is(200));
221+
@SuppressWarnings("unchecked")
222+
final Map<String, Object> response1 = res1.readEntity(Map.class);
223+
assertThat("token1 should have MFA=true", response1.get("mfaAuthenticated"), is(true));
224+
225+
// Test token2 returns MFA=false
226+
final URI target2 = UriBuilder.fromUri(host).path("/api/V2/token").build();
227+
final WebTarget wt2 = CLI.target(target2);
228+
final Builder req2 = wt2.request().header("authorization", token2.getToken());
229+
final Response res2 = req2.get();
230+
231+
assertThat("incorrect response code for token2", res2.getStatus(), is(200));
232+
@SuppressWarnings("unchecked")
233+
final Map<String, Object> response2 = res2.readEntity(Map.class);
234+
assertThat("token2 should have MFA=false", response2.get("mfaAuthenticated"), is(false));
235+
}
236+
183237
@Test
184238
public void getTokenFailNoToken() throws Exception {
185239
final URI target = UriBuilder.fromUri(host).path("/api/V2/token").build();

0 commit comments

Comments
 (0)