diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 1ecd3193..7b8e75c5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -2,6 +2,9 @@ ## 0.7.2 +* Added MFA (Multi-Factor Authentication) status tracking for tokens +* The `/api/V2/token` endpoint now returns an `mfa` field +* ORCID provider updated to use OpenID Connect scope for MFA detection * BACKWARDS INCOMPATIBILITY: Repeated or trailing underscores are no longer allowed in usernames. Existing usernames are unaffected. * Fixed a bug where usernames with underscores would not be matched in username searches if an diff --git a/src/main/java/us/kbase/auth2/lib/Authentication.java b/src/main/java/us/kbase/auth2/lib/Authentication.java index 24948f70..310bba33 100644 --- a/src/main/java/us/kbase/auth2/lib/Authentication.java +++ b/src/main/java/us/kbase/auth2/lib/Authentication.java @@ -77,6 +77,7 @@ import us.kbase.auth2.lib.exceptions.UserExistsException; import us.kbase.auth2.lib.identity.IdentityProvider; import us.kbase.auth2.lib.identity.RemoteIdentity; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentityID; import us.kbase.auth2.lib.storage.AuthStorage; import us.kbase.auth2.lib.storage.exceptions.AuthStorageException; @@ -745,13 +746,22 @@ public void forceResetAllPasswords(final IncomingToken token) private NewToken login(final UserName userName, final TokenCreationContext tokenCtx) throws AuthStorageException { + return login(userName, tokenCtx, MfaStatus.UNKNOWN); + } + + private NewToken login( + final UserName userName, + final TokenCreationContext tokenCtx, + final MfaStatus mfa + ) throws AuthStorageException { final NewToken nt = new NewToken(StoredToken.getBuilder( - TokenType.LOGIN, randGen.randomUUID(), userName) - .withLifeTime(clock.instant(), - cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.LOGIN)) - .withContext(tokenCtx) - .build(), - randGen.getToken()); + TokenType.LOGIN, randGen.randomUUID(), userName) + .withLifeTime(clock.instant(), + cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.LOGIN)) + .withContext(tokenCtx) + .withMfa(mfa) + .build(), + randGen.getToken()); storage.storeToken(nt.getStoredToken(), nt.getTokenHash()); setLastLogin(userName); logInfo("Logged in user {} with token {}", @@ -905,7 +915,8 @@ public NewToken createToken( final NewToken nt = new NewToken(StoredToken.getBuilder(tokenType, id, au.getUserName()) .withLifeTime(clock.instant(), life) .withContext(tokenCtx) - .withTokenName(tokenName).build(), + .withTokenName(tokenName) + .build(), randGen.getToken()); storage.storeToken(nt.getStoredToken(), nt.getTokenHash()); logInfo("User {} created {} token {}", au.getUserName().getName(), tokenType, id); @@ -1789,6 +1800,7 @@ public LoginToken login( // enough args here to start considering a builder storage.deleteTemporarySessionData(token.getHashedToken()); final Set ris = idp.getIdentities( authcode, tids.getPKCECodeVerifier().get(), false, environment); + final MfaStatus mfa = ris.iterator().next().getDetails().getMfa(); final LoginState lstate = getLoginState(ris, Instant.MIN); final ProviderConfig pc = cfg.getAppConfig().getProviderConfig(idp.getProviderName()); final LoginToken loginToken; @@ -1801,23 +1813,23 @@ public LoginToken login( // enough args here to start considering a builder * this call may be the result of a redirect from a 3rd party * provider. Any controllable error should be thrown when the process flow is back * under the control of the primary auth UI. - * + * * There's a tiny chance here that the user could be made an admin, be enabled, or * non-admin login be enabled between this step and the getLoginState() step. The * consequence is that they'll have to choose from one account in the next step, * so who cares. */ if (!cfg.getAppConfig().isLoginAllowed() && !Role.isAdmin(user.getRoles())) { - loginToken = storeIdentitiesTemporarily(lstate); + loginToken = storeIdentitiesTemporarily(lstate, mfa); } else if (user.isDisabled()) { - loginToken = storeIdentitiesTemporarily(lstate); + loginToken = storeIdentitiesTemporarily(lstate, mfa); } else { - loginToken = new LoginToken(login(user.getUserName(), tokenCtx)); + loginToken = new LoginToken(login(user.getUserName(), tokenCtx, mfa)); } } else { // store the identities so the user can create an account or choose from more than one // account - loginToken = storeIdentitiesTemporarily(lstate); + loginToken = storeIdentitiesTemporarily(lstate, mfa); } return loginToken; } @@ -1831,13 +1843,13 @@ private void checkState(final TemporarySessionData tids, final String state) } // ignores expiration date of login state - private LoginToken storeIdentitiesTemporarily(final LoginState ls) + private LoginToken storeIdentitiesTemporarily(final LoginState ls, final MfaStatus mfa) throws AuthStorageException { final Set store = new HashSet<>(ls.getIdentities()); ls.getUsers().stream().forEach(u -> store.addAll(ls.getIdentities(u))); final TemporarySessionData data = TemporarySessionData.create( randGen.randomUUID(), clock.instant(), LOGIN_TOKEN_LIFETIME_MS) - .login(store); + .login(store, mfa); final TemporaryToken tt = storeTemporarySessionData(data); logInfo("Stored temporary token {} with {} login identities", tt.getId(), store.size()); return new LoginToken(tt); @@ -2033,16 +2045,19 @@ public NewToken createUser( public NewToken testModeCreateToken( final UserName userName, final TokenName tokenName, - final TokenType tokenType) + final TokenType tokenType, + final MfaStatus mfa) throws TestModeException, AuthStorageException, NoSuchUserException { ensureTestMode(); requireNonNull(userName, "userName"); requireNonNull(tokenType, "tokenType"); + requireNonNull(mfa, "mfa"); storage.testModeGetUser(userName); // ensure user exists final UUID id = randGen.randomUUID(); final NewToken nt = new NewToken(StoredToken.getBuilder(tokenType, id, userName) .withLifeTime(clock.instant(), TEST_MODE_DATA_LIFETIME_MS) .withNullableTokenName(tokenName) + .withMfa(mfa) .build(), randGen.getToken()); storage.testModeStoreToken(nt.getStoredToken(), nt.getTokenHash()); @@ -2339,7 +2354,8 @@ public NewToken login( linked, u.get().getUserName().getName()); } } - return login(u.get().getUserName(), tokenCtx); + final MfaStatus mfaStatus = ri.get().getDetails().getMfa(); + return login(u.get().getUserName(), tokenCtx, mfaStatus); } private Optional getIdentity( @@ -3136,7 +3152,7 @@ public AuthConfigSetWithUpdateTime getConfig( /** Returns the suggested cache time for tokens in milliseconds. * @return the suggested cache time. - * @throws AuthStorageException if an error occurred accessing the storage system. + * @throws AuthStorageException if an error occurred accessing the storage system. */ public long getSuggestedTokenCacheTime() throws AuthStorageException { return cfg.getAppConfig().getTokenLifetimeMS(TokenLifetimeType.EXT_CACHE); diff --git a/src/main/java/us/kbase/auth2/lib/TemporarySessionData.java b/src/main/java/us/kbase/auth2/lib/TemporarySessionData.java index a99d384d..efb11935 100644 --- a/src/main/java/us/kbase/auth2/lib/TemporarySessionData.java +++ b/src/main/java/us/kbase/auth2/lib/TemporarySessionData.java @@ -14,6 +14,7 @@ import java.util.UUID; import us.kbase.auth2.lib.exceptions.ErrorType; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentity; /** Temporary session data that may include a set of temporary identities and / or an associated @@ -33,6 +34,7 @@ public class TemporarySessionData { private final String error; private final ErrorType errorType; private final UserName user; + private final MfaStatus mfa; private TemporarySessionData( final Operation op, @@ -44,7 +46,8 @@ private TemporarySessionData( final Set identities, final UserName user, final String error, - final ErrorType errorType) { + final ErrorType errorType, + final MfaStatus mfa) { this.op = op; this.id = id; this.created = created; @@ -55,6 +58,7 @@ private TemporarySessionData( this.user = user; this.error = error; this.errorType = errorType; + this.mfa = requireNonNull(mfa, "mfa"); } /** Get the operation this temporary session data supports. @@ -137,10 +141,17 @@ public boolean hasError() { return error != null; } + /** Get the multi-factor authentication status. + * @return the MFA status. + */ + public MfaStatus getMfa() { + return mfa; + } + @Override public int hashCode() { - return Objects.hash(created, error, errorType, expires, id, identities, oauth2State, op, pkceCodeVerifier, - user); + return Objects.hash(created, error, errorType, expires, id, identities, mfa, oauth2State, op, + pkceCodeVerifier, user); } @Override @@ -158,7 +169,7 @@ public boolean equals(Object obj) { return Objects.equals(created, other.created) && Objects.equals(error, other.error) && errorType == other.errorType && Objects.equals(expires, other.expires) && Objects.equals(id, other.id) && Objects.equals(identities, other.identities) - && Objects.equals(oauth2State, other.oauth2State) && op == other.op + && mfa == other.mfa && Objects.equals(oauth2State, other.oauth2State) && op == other.op && Objects.equals(pkceCodeVerifier, other.pkceCodeVerifier) && Objects.equals(user, other.user); } @@ -242,7 +253,7 @@ public TemporarySessionData error(final String error, final ErrorType errorType) requireNonNull(errorType, "errorType"); return new TemporarySessionData( Operation.ERROR, id, created, expires, - null, null, null, null, error, errorType); + null, null, null, null, error, errorType, MfaStatus.UNKNOWN); } /** Create temporary session data for the start of a login operation. @@ -258,7 +269,7 @@ public TemporarySessionData login( checkStringNoCheckedException(pkceCodeVerifier, "pkceCodeVerifier"); return new TemporarySessionData( Operation.LOGINSTART, id, created, expires, - oauth2State, pkceCodeVerifier, null, null, null, null); + oauth2State, pkceCodeVerifier, null, null, null, null, MfaStatus.UNKNOWN); } /** Create temporary session data for a login operation where remote identities are @@ -267,9 +278,20 @@ public TemporarySessionData login( * @return the temporary session data. */ public TemporarySessionData login(final Set identities) { + return login(identities, MfaStatus.UNKNOWN); + } + + /** Create temporary session data for a login operation where remote identities are + * involved. + * @param identities the remote identities involved in the login. + * @param mfa the multi-factor authentication status. + * @return the temporary session data. + */ + public TemporarySessionData login(final Set identities, final MfaStatus mfa) { + requireNonNull(mfa, "mfa"); return new TemporarySessionData( Operation.LOGINIDENTS, id, created, expires, - null, null, checkIdents(identities), null, null, null); + null, null, checkIdents(identities), null, null, null, mfa); } private Set checkIdents(final Set identities) { @@ -299,7 +321,7 @@ public TemporarySessionData link( requireNonNull(userName, "userName"); return new TemporarySessionData( Operation.LINKSTART, id, created, expires, - oauth2State, pkceCodeVerifier, null, userName, null, null); + oauth2State, pkceCodeVerifier, null, userName, null, null, MfaStatus.UNKNOWN); } /** Create temporary session data for a linking operation when remote identities are @@ -314,7 +336,7 @@ public TemporarySessionData link( requireNonNull(userName, "userName"); return new TemporarySessionData( Operation.LINKIDENTS, id, created, expires, - null, null, checkIdents(identities), userName, null, null); + null, null, checkIdents(identities), userName, null, null, MfaStatus.UNKNOWN); } } } diff --git a/src/main/java/us/kbase/auth2/lib/identity/MfaStatus.java b/src/main/java/us/kbase/auth2/lib/identity/MfaStatus.java new file mode 100644 index 00000000..651b6abe --- /dev/null +++ b/src/main/java/us/kbase/auth2/lib/identity/MfaStatus.java @@ -0,0 +1,65 @@ +package us.kbase.auth2.lib.identity; +// TODO CODE move to lib/token +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** An enumeration representing the multi-factor authentication status of a user's login. + * @author dlyon@lbl.gov + * + */ +public enum MfaStatus { + + /* first arg is ID, second arg is description. ID CANNOT change + * since that field is stored in the DB. + */ + /** User authenticated with MFA during token creation. */ + USED ("Used", "MFA used"), + /** User explicitly chose not to use MFA when available. */ + NOT_USED ("NotUsed", "MFA not used"), + /** MFA status unknown or not applicable to authentication method. */ + UNKNOWN ("Unknown", "MFA status unknown"); + + private static final Map STATUS_MAP = new HashMap<>(); + static { + for (final MfaStatus status: MfaStatus.values()) { + STATUS_MAP.put(status.getID(), status); + } + } + + private final String id; + private final String description; + + private MfaStatus(final String id, final String description) { + this.id = id; + this.description = description; + } + + /** Get the ID of this MFA status. + * @return the ID. + */ + @JsonValue + public String getID() { + return id; + } + + /** Get the description of this MFA status. + * @return the description. + */ + public String getDescription() { + return description; + } + + /** Get an MFA status from its ID. + * @param id the ID of the MFA status. + * @return the MFA status. + * @throws IllegalArgumentException if there is no MFA status matching the ID. + */ + public static MfaStatus fromID(final String id) { + if (!STATUS_MAP.containsKey(id)) { + throw new IllegalArgumentException("Invalid MFA status: " + id); + } + return STATUS_MAP.get(id); + } +} diff --git a/src/main/java/us/kbase/auth2/lib/identity/RemoteIdentityDetails.java b/src/main/java/us/kbase/auth2/lib/identity/RemoteIdentityDetails.java index dcbf6e01..4d5491bd 100644 --- a/src/main/java/us/kbase/auth2/lib/identity/RemoteIdentityDetails.java +++ b/src/main/java/us/kbase/auth2/lib/identity/RemoteIdentityDetails.java @@ -1,7 +1,25 @@ package us.kbase.auth2.lib.identity; +import static java.util.Objects.requireNonNull; + /** A set of potentially mutable details about a remote identity. The identity provider may * change these details at any time. + * + * Important Note on MFA Status: The MFA (multi-factor authentication) status field in this + * class is session-only and is never persisted to identity documents in the database. The MFA + * status is extracted during OAuth callbacks from the identity provider and flows through to + * token creation, where it is stored on the token itself. When identities are loaded from the + * database, the MFA status will always be {@link MfaStatus#UNKNOWN} since it is not stored. + * This is by design - MFA status is transient information relevant only to the current + * authentication session. + * + * TODO: MFA field is conceptually different from other fields in this class. Username, fullname, + * and email are persistent identity attributes stored in the database. MFA status is + * transient session data that never persists. Ideally these should be separated via a + * wrapper class (e.g., RemoteIdentityWithSession containing RemoteIdentity + MfaStatus), + * but this would require significant refactoring for modest benefit. Current approach + * works correctly but conflates two different types of data in one class. + * * @author gaprice@lbl.gov * */ @@ -10,6 +28,7 @@ public class RemoteIdentityDetails { private final String username; private final String fullname; private final String email; + private final MfaStatus mfa; /** Create a new set of details. * @param username the user name of the identity. @@ -20,6 +39,20 @@ public RemoteIdentityDetails( final String username, final String fullname, final String email) { + this(username, fullname, email, MfaStatus.UNKNOWN); + } + + /** Create a new set of details. + * @param username the user name of the identity. + * @param fullname the full name of the identity. Null is acceptable. + * @param email the email address of the identity. Null is acceptable. + * @param mfa the multi-factor authentication status. + */ + public RemoteIdentityDetails( + final String username, + final String fullname, + final String email, + final MfaStatus mfa) { super(); if (username == null || username.trim().isEmpty()) { throw new IllegalArgumentException( @@ -36,6 +69,7 @@ public RemoteIdentityDetails( } else { this.email = email.trim(); } + this.mfa = requireNonNull(mfa, "mfa"); } /** Get the user name for the identity. @@ -57,6 +91,13 @@ public String getFullname() { public String getEmail() { return email; } + + /** Get the multi-factor authentication status. + * @return the MFA status. + */ + public MfaStatus getMfa() { + return mfa; + } @Override public int hashCode() { @@ -64,6 +105,7 @@ public int hashCode() { int result = 1; result = prime * result + ((email == null) ? 0 : email.hashCode()); result = prime * result + ((fullname == null) ? 0 : fullname.hashCode()); + result = prime * result + ((mfa == null) ? 0 : mfa.hashCode()); result = prime * result + ((username == null) ? 0 : username.hashCode()); return result; } @@ -94,6 +136,13 @@ public boolean equals(Object obj) { } else if (!fullname.equals(other.fullname)) { return false; } + if (mfa == null) { + if (other.mfa != null) { + return false; + } + } else if (!mfa.equals(other.mfa)) { + return false; + } if (username == null) { if (other.username != null) { return false; @@ -113,6 +162,8 @@ public String toString() { builder.append(fullname); builder.append(", email="); builder.append(email); + builder.append(", mfa="); + builder.append(mfa); builder.append("]"); return builder.toString(); } diff --git a/src/main/java/us/kbase/auth2/lib/storage/mongo/Fields.java b/src/main/java/us/kbase/auth2/lib/storage/mongo/Fields.java index b05aba6d..268d9d61 100644 --- a/src/main/java/us/kbase/auth2/lib/storage/mongo/Fields.java +++ b/src/main/java/us/kbase/auth2/lib/storage/mongo/Fields.java @@ -97,6 +97,11 @@ public class Fields { public static final String IDENTITIES_NAME = "fullname"; /** The email address of the identity. */ public static final String IDENTITIES_EMAIL = "email"; + /** + * Whether the identity was authenticated with multi-factor authentication. + * Only used during temporary identity storage during the login process + */ + public static final String IDENTITIES_MFA = "mfa"; /* ************** * token fields @@ -135,6 +140,8 @@ public class Fields { public static final String TOKEN_CUSTOM_KEY = "k"; /** A value for a custom context key / value pair. */ public static final String TOKEN_CUSTOM_VALUE = "v"; + /** Whether the token was created with multi-factor authentication. */ + public static final String TOKEN_MFA = "mfa"; /* ************************ * temporary session data fields @@ -163,7 +170,9 @@ public class Fields { public static final String TEMP_SESSION_ERROR = "err"; /** The type of the error associated with the temporary token. */ public static final String TEMP_SESSION_ERROR_TYPE = "errtype"; - + /** The multi-factor authentication status associated with the temporary token. */ + public static final String TEMP_SESSION_MFA = "mfa"; + /* ******************** * custom roles fields * ******************** diff --git a/src/main/java/us/kbase/auth2/lib/storage/mongo/MongoStorage.java b/src/main/java/us/kbase/auth2/lib/storage/mongo/MongoStorage.java index f2e520f6..cfad757b 100644 --- a/src/main/java/us/kbase/auth2/lib/storage/mongo/MongoStorage.java +++ b/src/main/java/us/kbase/auth2/lib/storage/mongo/MongoStorage.java @@ -90,6 +90,7 @@ import us.kbase.auth2.lib.exceptions.UserExistsException; import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentityID; import us.kbase.auth2.lib.storage.AuthStorage; import us.kbase.auth2.lib.storage.exceptions.AuthStorageException; @@ -568,6 +569,11 @@ private DisplayName getDisplayName(final String displayName) throws AuthStorageE } } + private MfaStatus getMfaStatus(final String mfaString) { + // for backwards compatibility with versions of auth older than 0.7.2 + return mfaString != null ? MfaStatus.fromID(mfaString) : MfaStatus.UNKNOWN; + } + private EmailAddress getEmail(final String email) throws AuthStorageException { if (email == null) { return EmailAddress.UNKNOWN; @@ -871,7 +877,8 @@ private void storeToken(final String collection, final StoredToken token, final .append(Fields.TOKEN_DEVICE, ctx.getDevice().orElse(null)) .append(Fields.TOKEN_IP, ctx.getIpAddress().isPresent() ? ctx.getIpAddress().get().getHostAddress() : null) - .append(Fields.TOKEN_CUSTOM_CONTEXT, toCustomContextList(ctx.getCustomContext())); + .append(Fields.TOKEN_CUSTOM_CONTEXT, toCustomContextList(ctx.getCustomContext())) + .append(Fields.TOKEN_MFA, token.getMfa().getID()); try { db.getCollection(collection).insertOne(td); } catch (MongoWriteException mwe) { @@ -969,6 +976,7 @@ private StoredToken getToken(final Document t) throws AuthStorageException { t.getDate(Fields.TOKEN_EXPIRY).toInstant()) .withNullableTokenName(getTokenName(t.getString(Fields.TOKEN_NAME))) .withContext(toTokenCreationContext(t)) + .withMfa(getMfaStatus(t.getString(Fields.TOKEN_MFA))) .build(); } @@ -1575,7 +1583,17 @@ public Optional getUser(final RemoteIdentity remoteID) throws AuthStor b.withIdentity(ri); } } - b.withIdentity(remoteID); + /* The remoteID parameter may contain MFA status from the current session (e.g., USED), + * but MFA is never persisted to the database. The returned user must reflect the + * database state with MFA=UNKNOWN. Using the 3-parameter constructor defaults to UNKNOWN. + */ + final RemoteIdentity updatedIdentity = new RemoteIdentity( + remoteID.getRemoteID(), + new RemoteIdentityDetails( + remoteID.getDetails().getUsername(), + remoteID.getDetails().getFullname(), + remoteID.getDetails().getEmail())); + b.withIdentity(updatedIdentity); user = b.build(); updateIdentity(remoteID); } @@ -1590,9 +1608,12 @@ private Document makeUserQuery(final RemoteIdentity remoteID) { private void updateIdentity(final RemoteIdentity remoteID) throws AuthStorageException { final Document query = makeUserQuery(remoteID); - + final String pre = Fields.USER_IDENTITIES + ".$."; final RemoteIdentityDetails rid = remoteID.getDetails(); + // Note: MFA status is intentionally NOT persisted to identity documents. + // MFA is session-only data that flows through to token creation and is stored on tokens. + // When identities are loaded from DB, MFA will always be UNKNOWN. final Document update = new Document("$set", new Document(pre + Fields.IDENTITIES_USER, rid.getUsername()) .append(pre + Fields.IDENTITIES_EMAIL, rid.getEmail()) @@ -1695,7 +1716,8 @@ public void storeTemporarySessionData(final TemporarySessionData data, final Str data.getErrorType().get().getErrorCode() : null) .append(Fields.TEMP_SESSION_IDENTITIES, ids) .append(Fields.TEMP_SESSION_USER, - data.getUser().isPresent() ? data.getUser().get().getName() : null); + data.getUser().isPresent() ? data.getUser().get().getName() : null) + .append(Fields.TEMP_SESSION_MFA, data.getMfa().getID()); storeTemporarySessionData(td); } @@ -1754,6 +1776,7 @@ public TemporarySessionData getTemporarySessionData( final TemporarySessionData tis; @SuppressWarnings("unchecked") final List ids = (List) d.get(Fields.TEMP_SESSION_IDENTITIES); + final MfaStatus mfa = MfaStatus.fromID(d.getString(Fields.TEMP_SESSION_MFA)); if (op.equals(Operation.ERROR)) { tis = b.error(d.getString(Fields.TEMP_SESSION_ERROR), ErrorType.fromErrorCode(d.getInteger(Fields.TEMP_SESSION_ERROR_TYPE))); @@ -1763,7 +1786,7 @@ public TemporarySessionData getTemporarySessionData( d.getString(Fields.TEMP_SESSION_PKCE_CODE_VERIFIER) ); } else if (op.equals(Operation.LOGINIDENTS)) { - tis = b.login(toIdentities(ids)); + tis = b.login(toIdentities(ids), mfa); } else if (op.equals(Operation.LINKSTART)) { tis = b.link( d.getString(Fields.TEMP_SESSION_OAUTH2STATE), @@ -1786,6 +1809,8 @@ private Set toIdentities(final List ids) { final RemoteIdentityID rid = new RemoteIdentityID( i.getString(Fields.IDENTITIES_PROVIDER), i.getString(Fields.IDENTITIES_PROV_ID)); + // MFA is never persisted on identities - it's session-only data stored on tokens. + // Identities loaded from DB always have MFA status of UNKNOWN. final RemoteIdentityDetails det = new RemoteIdentityDetails( i.getString(Fields.IDENTITIES_USER), i.getString(Fields.IDENTITIES_NAME), diff --git a/src/main/java/us/kbase/auth2/lib/token/StoredToken.java b/src/main/java/us/kbase/auth2/lib/token/StoredToken.java index cdedf822..a01d9d1b 100644 --- a/src/main/java/us/kbase/auth2/lib/token/StoredToken.java +++ b/src/main/java/us/kbase/auth2/lib/token/StoredToken.java @@ -8,6 +8,7 @@ import us.kbase.auth2.lib.TokenCreationContext; import us.kbase.auth2.lib.UserName; +import us.kbase.auth2.lib.identity.MfaStatus; /** A token associated with a user stored in the authentication storage system. * @@ -23,6 +24,7 @@ public class StoredToken { private final UserName userName; private final Instant creationDate; private final Instant expirationDate; + private final MfaStatus mfa; private StoredToken( final UUID id, @@ -31,7 +33,8 @@ private StoredToken( final UserName userName, final TokenCreationContext context, final Instant creationDate, - final Instant expirationDate) { + final Instant expirationDate, + final MfaStatus mfa) { // this stuff is here just in case naughty users use casting to skip a builder step requireNonNull(creationDate, "created"); // no way to test this one @@ -43,6 +46,7 @@ private StoredToken( this.expirationDate = expirationDate; this.creationDate = creationDate; this.id = id; + this.mfa = mfa; } /** Get the type of the token. @@ -93,6 +97,13 @@ public Instant getCreationDate() { public Instant getExpirationDate() { return expirationDate; } + + /** Get the multi-factor authentication status for this token. + * @return the MFA status. + */ + public MfaStatus getMfa() { + return mfa; + } @Override public int hashCode() { @@ -105,6 +116,7 @@ public int hashCode() { result = prime * result + ((tokenName == null) ? 0 : tokenName.hashCode()); result = prime * result + ((type == null) ? 0 : type.hashCode()); result = prime * result + ((userName == null) ? 0 : userName.hashCode()); + result = prime * result + mfa.hashCode(); return result; } @@ -165,6 +177,9 @@ public boolean equals(Object obj) { } else if (!userName.equals(other.userName)) { return false; } + if (!mfa.equals(other.mfa)) { + return false; + } return true; } @@ -224,6 +239,12 @@ public interface OptionalsStep { */ OptionalsStep withContext(TokenCreationContext context); + /** Specify whether the token was created with multi-factor authentication. + * @param mfa the MFA status. + * @return this builder. + */ + OptionalsStep withMfa(MfaStatus mfa); + /** Build the token. * @return a new StoredToken. */ @@ -239,6 +260,7 @@ private static class Builder implements LifeStep, OptionalsStep { private final UserName userName; private Instant creationDate; private Instant expirationDate; + private MfaStatus mfa = MfaStatus.UNKNOWN; private Builder(final TokenType type, final UUID id, final UserName userName) { requireNonNull(type, "type"); @@ -269,10 +291,16 @@ public OptionalsStep withContext(final TokenCreationContext context) { return this; } + @Override + public OptionalsStep withMfa(final MfaStatus mfa) { + this.mfa = requireNonNull(mfa, "mfa"); + return this; + } + @Override public StoredToken build() { return new StoredToken(id, type, tokenName, userName, context, - creationDate, expirationDate); + creationDate, expirationDate, mfa); } @Override diff --git a/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java b/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java index e93a357b..279ea2f7 100644 --- a/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java +++ b/src/main/java/us/kbase/auth2/providers/OrcIDIdentityProviderFactory.java @@ -7,6 +7,7 @@ import java.net.URISyntaxException; import java.net.URL; import java.util.Arrays; +import java.util.Base64; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -26,6 +27,8 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.LoggerFactory; + import us.kbase.auth2.lib.exceptions.IdentityRetrievalException; import us.kbase.auth2.lib.exceptions.NoSuchEnvironmentException; import us.kbase.auth2.lib.identity.IdentityProvider; @@ -33,6 +36,7 @@ import us.kbase.auth2.lib.identity.IdentityProviderFactory; import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentityID; /** A factory for a OrcID identity provider. @@ -47,28 +51,38 @@ public IdentityProvider configure(final IdentityProviderConfig cfg) { } /** An identity provider for OrcID accounts. + * + * Multi-Factor Authentication (MFA) Status Handling: + * - Uses OpenID Connect JWT tokens to determine MFA status via AMR claims + * - Configuration option "orcid-mfa-enabled" (default: true): + * - true: Requires OpenID scope, throws error on missing or malformed JWT + * - false: Skips MFA check, returns MfaStatus.UNKNOWN (for non-member API apps) + * - Valid JWT with AMR claim: returns MfaStatus.USED or MfaStatus.NOT_USED based on "mfa" presence + * * @author gaprice@lbl.gov * */ public static class OrcIDIdentityProvider implements IdentityProvider { - + // notes: I haven't been able to find documentation re the OrcID error structure, so I've // reversed engineered it by passing bad input. Hopefully what I've got covers all the // possibilities. - + /* Get creds: https://sandbox.orcid.org/developer-tools */ - + private static final String NAME = "OrcID"; - private static final String SCOPE = "/authenticate"; + private static final String SCOPE_OPENID = "openid /authenticate"; + private static final String SCOPE_NO_OPENID = "/authenticate"; private static final String LOGIN_PATH = "/oauth/authorize"; private static final String TOKEN_PATH = "/oauth/token"; private static final String RECORD_PATH = "/v2.1"; + private static final String CFG_MFA_ENABLED = "orcid-mfa-enabled"; //thread safe private static final Client CLI = ClientBuilder.newClient(); - + private static final ObjectMapper MAPPER = new ObjectMapper(); - + private final IdentityProviderConfig cfg; /** Create an identity provider for OrcID. @@ -105,9 +119,13 @@ public URI getLoginURI( throws NoSuchEnvironmentException { // note that OrcID does not currently implement PKCE so we ignore the code // challenge: https://github.com/ORCID/ORCID-Source/issues/5977 + final boolean mfaEnabled = Boolean.parseBoolean( + cfg.getCustomConfiguation().getOrDefault(CFG_MFA_ENABLED, "true")); + final String scope = mfaEnabled ? SCOPE_OPENID : SCOPE_NO_OPENID; + return UriBuilder.fromUri(toURI(cfg.getLoginURL())) .path(LOGIN_PATH) - .queryParam("scope", SCOPE) + .queryParam("scope", scope) .queryParam("state", state) .queryParam("redirect_uri", getRedirectURL(link, environment)) .queryParam("response_type", "code") @@ -174,7 +192,8 @@ private RemoteIdentity getIdentity(final OrcIDAccessTokenResponse accessToken) new RemoteIdentityDetails( accessToken.orcID, accessToken.fullName, - email)); + email, + accessToken.mfa)); } private Map orcIDGetRequest( @@ -205,17 +224,81 @@ public void handleError(final Response r, final Map response) } } } + + /** + * Parses the Authentication Method Reference (AMR) claim from an OpenID Connect ID token + * to determine if multi-factor authentication was used. + * + * @param jwt the JWT ID token from ORCID + * @return MfaStatus indicating whether MFA was used + * @throws IdentityRetrievalException if JWT is missing, malformed, or unparseable + */ + private MfaStatus parseAmrClaim(final String jwt) throws IdentityRetrievalException { + if (jwt == null || jwt.trim().isEmpty()) { + throw new IdentityRetrievalException( + "No JWT token provided by ORCID. For non-member API applications, " + + "set orcid-mfa-enabled=false in provider configuration"); + } + + // JWT format: header.payload.signature + final String[] parts = jwt.split("\\."); + if (parts.length != 3) { + // Invalid JWT format + throw new IdentityRetrievalException("Invalid JWT format from ORCID: expected 3 parts, got " + parts.length); + } + + // Decode the payload (second part) - URL-safe base64 + final String payload; + try { + payload = new String(Base64.getUrlDecoder().decode(parts[1])); + } catch (IllegalArgumentException e) { + // Base64 decoding failed - invalid JWT format + LoggerFactory.getLogger(OrcIDIdentityProviderFactory.class).warn("Unable to decode JWT from ORCID: {}", e.getMessage()); + throw new IdentityRetrievalException("Unable to decode JWT from ORCID: " + e.getMessage(), e); + } + + // Parse JSON payload to extract claims + final Map claims; + try { + @SuppressWarnings("unchecked") + final Map parsedClaims = MAPPER.readValue(payload, Map.class); + claims = parsedClaims; + } catch (IOException e) { + // JSON parsing failed - malformed payload + LoggerFactory.getLogger(OrcIDIdentityProviderFactory.class).warn("Unable to parse JWT payload from ORCID: {}", e.getMessage()); + throw new IdentityRetrievalException("Unable to parse JWT payload from ORCID: " + e.getMessage(), e); + } + + final Object amrClaim = claims.get("amr"); + if (amrClaim == null) { + // No AMR claim present - MFA status unknown + return MfaStatus.UNKNOWN; + } else if (amrClaim instanceof List) { + // OpenID Connect spec: AMR should be an array of strings + @SuppressWarnings("unchecked") + final List amrList = (List) amrClaim; + return amrList.contains("mfa") ? MfaStatus.USED : MfaStatus.NOT_USED; + } else if (amrClaim instanceof String) { + // ORCID may return single string - handle as fallback + return "mfa".equals(amrClaim) ? MfaStatus.USED : MfaStatus.NOT_USED; + } + + // AMR claim present but in unexpected format + throw new IdentityRetrievalException("AMR claim from ORCID in unexpected format: " + amrClaim); + } private static class OrcIDAccessTokenResponse { private final String accessToken; private final String fullName; private final String orcID; + private final MfaStatus mfa; private OrcIDAccessTokenResponse( final String accessToken, final String fullName, - final String orcID) + final String orcID, + final MfaStatus mfa) throws IdentityRetrievalException { if (accessToken == null || accessToken.trim().isEmpty()) { throw new IdentityRetrievalException( @@ -228,6 +311,7 @@ private OrcIDAccessTokenResponse( this.accessToken = accessToken.trim(); this.fullName = fullName == null ? null : fullName.trim(); this.orcID = orcID.trim(); + this.mfa = mfa; } } @@ -256,10 +340,25 @@ private OrcIDAccessTokenResponse getAccessToken( throw new IdentityRetrievalException("Authtoken retrieval failed: " + msg[msg.length - 1].trim()); } + + // Determine MFA status based on configuration + final boolean mfaEnabled = Boolean.parseBoolean( + cfg.getCustomConfiguation().getOrDefault(CFG_MFA_ENABLED, "true")); + final MfaStatus mfaStatus; + if (!mfaEnabled) { + // MFA checking disabled - no OpenID scope, so no id_token expected + mfaStatus = MfaStatus.UNKNOWN; + } else { + // MFA checking enabled - parse JWT from id_token + final String idToken = (String) m.get("id_token"); + mfaStatus = parseAmrClaim(idToken); + } + return new OrcIDAccessTokenResponse( (String) m.get("access_token"), (String) m.get("name"), - (String) m.get("orcid")); + (String) m.get("orcid"), + mfaStatus); } private Map orcIDPostRequest( diff --git a/src/main/java/us/kbase/auth2/service/api/TestMode.java b/src/main/java/us/kbase/auth2/service/api/TestMode.java index 73c46d1b..72160b4e 100644 --- a/src/main/java/us/kbase/auth2/service/api/TestMode.java +++ b/src/main/java/us/kbase/auth2/service/api/TestMode.java @@ -123,15 +123,26 @@ public static class CreateTestToken extends IncomingJSON { public final String userName; public final String tokenName; public final String tokenType; + public final String mfa; + + // Backwards compatible constructor for tests + public CreateTestToken( + final String userName, + final String tokenName, + final String tokenType) { + this(userName, tokenName, tokenType, null); + } @JsonCreator public CreateTestToken( @JsonProperty(Fields.USER) final String userName, @JsonProperty(Fields.TOKEN_NAME) final String tokenName, - @JsonProperty(Fields.TOKEN_TYPE) final String tokenType) { + @JsonProperty(Fields.TOKEN_TYPE) final String tokenType, + @JsonProperty("mfa") final String mfa) { this.userName = userName; this.tokenName = tokenName; this.tokenType = tokenType; + this.mfa = mfa; } } @@ -164,7 +175,8 @@ public NewAPIToken createTestToken(final CreateTestToken create) return new NewAPIToken(auth.testModeCreateToken( new UserName(create.userName), create.tokenName == null ? null : new TokenName(create.tokenName), - getTokenType(create.tokenType)), + getTokenType(create.tokenType), + getMfaStatus(create.mfa)), auth.getSuggestedTokenCacheTime()); } @@ -175,6 +187,18 @@ private TokenType getTokenType(final String tokenType) throws IllegalParameterEx throw new IllegalParameterException(e.getMessage(), e); } } + + private us.kbase.auth2.lib.identity.MfaStatus getMfaStatus(final String mfa) + throws IllegalParameterException { + if (mfa == null) { + return us.kbase.auth2.lib.identity.MfaStatus.UNKNOWN; + } + try { + return us.kbase.auth2.lib.identity.MfaStatus.fromID(mfa); + } catch (IllegalArgumentException e) { + throw new IllegalParameterException(e.getMessage(), e); + } + } @GET @Path(APIPaths.TESTMODE_TOKEN) diff --git a/src/main/java/us/kbase/auth2/service/api/Token.java b/src/main/java/us/kbase/auth2/service/api/Token.java index 234a1131..c9ac0e6d 100644 --- a/src/main/java/us/kbase/auth2/service/api/Token.java +++ b/src/main/java/us/kbase/auth2/service/api/Token.java @@ -29,6 +29,7 @@ import us.kbase.auth2.lib.exceptions.NoTokenProvidedException; import us.kbase.auth2.lib.exceptions.UnauthorizedException; import us.kbase.auth2.lib.storage.exceptions.AuthStorageException; +import us.kbase.auth2.lib.token.IncomingToken; import us.kbase.auth2.lib.token.StoredToken; import us.kbase.auth2.lib.token.TokenName; import us.kbase.auth2.lib.token.TokenType; @@ -55,7 +56,8 @@ public Token(final Authentication auth, final UserAgentParser userAgentParser) { @Produces(MediaType.APPLICATION_JSON) public APIToken viewToken(@HeaderParam(APIConstants.HEADER_TOKEN) final String token) throws NoTokenProvidedException, InvalidTokenException, AuthStorageException { - final StoredToken ht = auth.getToken(getToken(token)); + final IncomingToken it = getToken(token); + final StoredToken ht = auth.getToken(it); return new APIToken(ht, auth.getSuggestedTokenCacheTime()); } diff --git a/src/main/java/us/kbase/auth2/service/common/ExternalToken.java b/src/main/java/us/kbase/auth2/service/common/ExternalToken.java index 363a0c55..0e07a00f 100644 --- a/src/main/java/us/kbase/auth2/service/common/ExternalToken.java +++ b/src/main/java/us/kbase/auth2/service/common/ExternalToken.java @@ -4,6 +4,7 @@ import java.util.Map; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.token.StoredToken; public class ExternalToken { @@ -17,6 +18,7 @@ public class ExternalToken { private final String name; private final String user; private final Map custom; + private final MfaStatus mfa; public ExternalToken(final StoredToken storedToken) { requireNonNull(storedToken, "storedToken"); @@ -28,6 +30,7 @@ public ExternalToken(final StoredToken storedToken) { expires = storedToken.getExpirationDate().toEpochMilli(); created = storedToken.getCreationDate().toEpochMilli(); custom = storedToken.getContext().getCustomContext(); + mfa = storedToken.getMfa(); } public String getType() { @@ -58,6 +61,16 @@ public Map getCustom() { return custom; } + /** + * Gets the MFA authentication status for this token. + * Returns UNKNOWN for tokens from identity providers that don't support MFA. + * + * @return the MFA authentication status. + */ + public MfaStatus getMfa() { + return mfa; + } + @Override public int hashCode() { final int prime = 31; @@ -66,6 +79,7 @@ public int hashCode() { result = prime * result + ((custom == null) ? 0 : custom.hashCode()); result = prime * result + (int) (expires ^ (expires >>> 32)); result = prime * result + ((id == null) ? 0 : id.hashCode()); + result = prime * result + mfa.hashCode(); result = prime * result + ((name == null) ? 0 : name.hashCode()); result = prime * result + ((type == null) ? 0 : type.hashCode()); result = prime * result + ((user == null) ? 0 : user.hashCode()); @@ -125,6 +139,9 @@ public boolean equals(Object obj) { } else if (!user.equals(other.user)) { return false; } + if (!mfa.equals(other.mfa)) { + return false; + } return true; } } diff --git a/src/test/java/us/kbase/test/auth2/lib/AuthenticationTestModeTokenTest.java b/src/test/java/us/kbase/test/auth2/lib/AuthenticationTestModeTokenTest.java index 84a1f0ec..ac8c2b26 100644 --- a/src/test/java/us/kbase/test/auth2/lib/AuthenticationTestModeTokenTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/AuthenticationTestModeTokenTest.java @@ -28,6 +28,7 @@ import us.kbase.auth2.lib.exceptions.NoSuchTokenException; import us.kbase.auth2.lib.exceptions.NoSuchUserException; import us.kbase.auth2.lib.exceptions.TestModeException; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.storage.AuthStorage; import us.kbase.auth2.lib.token.IncomingToken; import us.kbase.auth2.lib.token.NewToken; @@ -71,7 +72,7 @@ public void createTokenWithoutName() throws Exception { when(clock.instant()).thenReturn(Instant.ofEpochMilli(10000)); when(rand.getToken()).thenReturn("whee"); - final NewToken nt = auth.testModeCreateToken(new UserName("foo"), null, TokenType.AGENT); + final NewToken nt = auth.testModeCreateToken(new UserName("foo"), null, TokenType.AGENT, MfaStatus.UNKNOWN); assertThat("incorrect token", nt, is(new NewToken(StoredToken.getBuilder( TokenType.AGENT, id, new UserName("foo")) @@ -107,7 +108,7 @@ public void createTokenWithName() throws Exception { when(rand.getToken()).thenReturn("whee"); final NewToken nt = auth.testModeCreateToken( - new UserName("foo"), new TokenName("tok"), TokenType.SERV); + new UserName("foo"), new TokenName("tok"), TokenType.SERV, MfaStatus.UNKNOWN); assertThat("incorrect token", nt, is(new NewToken(StoredToken.getBuilder( TokenType.SERV, id, new UserName("foo")) @@ -160,7 +161,7 @@ private void failCreateToken( final TokenType tokenType, final Exception expected) { try { - auth.testModeCreateToken(userName, null, tokenType); + auth.testModeCreateToken(userName, null, tokenType, MfaStatus.UNKNOWN); fail("expected exception"); } catch (Exception got) { TestCommon.assertExceptionCorrect(got, expected); diff --git a/src/test/java/us/kbase/test/auth2/lib/TemporarySessionDataTest.java b/src/test/java/us/kbase/test/auth2/lib/TemporarySessionDataTest.java index c11b6513..b8465637 100644 --- a/src/test/java/us/kbase/test/auth2/lib/TemporarySessionDataTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/TemporarySessionDataTest.java @@ -20,6 +20,7 @@ import us.kbase.auth2.lib.TemporarySessionData.Operation; import us.kbase.auth2.lib.UserName; import us.kbase.auth2.lib.exceptions.ErrorType; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; import us.kbase.auth2.lib.identity.RemoteIdentityID; @@ -45,7 +46,7 @@ public void constructLoginStart() throws Exception { final UUID id = UUID.randomUUID(); final TemporarySessionData ti = TemporarySessionData.create(id, inst(10000), inst(20000)) .login("stategoeshere", "pkcegoeshere"); - + assertThat("incorrect op", ti.getOperation(), is(Operation.LOGINSTART)); assertThat("incorrect id", ti.getId(), is(id)); assertThat("incorrect created", ti.getCreated(), is(inst(10000))); @@ -57,6 +58,7 @@ public void constructLoginStart() throws Exception { assertThat("incorrect error", ti.getError(), is(Optional.empty())); assertThat("incorrect error type", ti.getErrorType(), is(Optional.empty())); assertThat("incorrect has error", ti.hasError(), is(false)); + assertThat("incorrect mfa", ti.getMfa(), is(MfaStatus.UNKNOWN)); } @Test @@ -65,7 +67,7 @@ public void constructLoginIdents() throws Exception { final Instant now = Instant.now(); final TemporarySessionData ti = TemporarySessionData.create( id, now, now.plusMillis(100000)).login(set(REMOTE1, REMOTE2)); - + assertThat("incorrect op", ti.getOperation(), is(Operation.LOGINIDENTS)); assertThat("incorrect id", ti.getId(), is(id)); assertThat("incorrect created", ti.getCreated(), is(now)); @@ -77,7 +79,8 @@ public void constructLoginIdents() throws Exception { assertThat("incorrect error", ti.getError(), is(Optional.empty())); assertThat("incorrect error type", ti.getErrorType(), is(Optional.empty())); assertThat("incorrect has error", ti.hasError(), is(false)); - + assertThat("incorrect mfa", ti.getMfa(), is(MfaStatus.UNKNOWN)); + assertImmutable(ti); } @@ -337,4 +340,94 @@ private void failConstructError( TestCommon.assertExceptionCorrect(got, e); } } + + @Test + public void constructLoginIdentsWithMfaUsed() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now(); + final TemporarySessionData ti = TemporarySessionData.create( + id, now, now.plusMillis(100000)).login(set(REMOTE1, REMOTE2), MfaStatus.USED); + + assertThat("incorrect op", ti.getOperation(), is(Operation.LOGINIDENTS)); + assertThat("incorrect id", ti.getId(), is(id)); + assertThat("incorrect created", ti.getCreated(), is(now)); + assertThat("incorrect expires", ti.getExpires(), is(now.plusMillis(100000))); + assertThat("incorrect state", ti.getOAuth2State(), is(ES)); + assertThat("incorrect pkce", ti.getPKCECodeVerifier(), is(ES)); + assertThat("incorrect user", ti.getUser(), is(Optional.empty())); + assertThat("incorrect idents", ti.getIdentities(), is(Optional.of(set(REMOTE2, REMOTE1)))); + assertThat("incorrect error", ti.getError(), is(Optional.empty())); + assertThat("incorrect error type", ti.getErrorType(), is(Optional.empty())); + assertThat("incorrect has error", ti.hasError(), is(false)); + assertThat("incorrect mfa", ti.getMfa(), is(MfaStatus.USED)); + + assertImmutable(ti); + } + + @Test + public void constructLoginIdentsWithMfaNotUsed() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now(); + final TemporarySessionData ti = TemporarySessionData.create( + id, now, now.plusMillis(100000)).login(set(REMOTE1), MfaStatus.NOT_USED); + + assertThat("incorrect mfa", ti.getMfa(), is(MfaStatus.NOT_USED)); + } + + @Test + public void constructLoginIdentsWithMfaUnknown() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now(); + final TemporarySessionData ti = TemporarySessionData.create( + id, now, now.plusMillis(100000)).login(set(REMOTE1), MfaStatus.UNKNOWN); + + assertThat("incorrect mfa", ti.getMfa(), is(MfaStatus.UNKNOWN)); + } + + @Test + public void getMfaDefaultsToUnknown() throws Exception { + // Test all operations default to UNKNOWN when MFA is not explicitly provided + assertThat("incorrect mfa for LOGINSTART", + TemporarySessionData.create(UUID.randomUUID(), Instant.now(), Instant.now()) + .login("state", "pkce").getMfa(), + is(MfaStatus.UNKNOWN)); + + assertThat("incorrect mfa for LOGINIDENTS without explicit MFA", + TemporarySessionData.create(UUID.randomUUID(), Instant.now(), Instant.now()) + .login(set(REMOTE1)).getMfa(), + is(MfaStatus.UNKNOWN)); + + assertThat("incorrect mfa for LINKSTART", + TemporarySessionData.create(UUID.randomUUID(), Instant.now(), Instant.now()) + .link("state", "pkce", new UserName("foo")).getMfa(), + is(MfaStatus.UNKNOWN)); + + assertThat("incorrect mfa for LINKIDENTS", + TemporarySessionData.create(UUID.randomUUID(), Instant.now(), Instant.now()) + .link(new UserName("foo"), set(REMOTE1)).getMfa(), + is(MfaStatus.UNKNOWN)); + + assertThat("incorrect mfa for ERROR", + TemporarySessionData.create(UUID.randomUUID(), Instant.now(), Instant.now()) + .error("error", ErrorType.DISABLED).getMfa(), + is(MfaStatus.UNKNOWN)); + } + + @Test + public void constructLoginIdentsWithMfaFailNull() throws Exception { + failConstructLoginIdentsWithMfa(set(REMOTE1), null, new NullPointerException("mfa")); + } + + private void failConstructLoginIdentsWithMfa( + final Set idents, + final MfaStatus mfa, + final Exception e) { + try { + TemporarySessionData.create(UUID.randomUUID(), Instant.now(), Instant.now()) + .login(idents, mfa); + fail("expected exception"); + } catch (Exception got) { + TestCommon.assertExceptionCorrect(got, e); + } + } } diff --git a/src/test/java/us/kbase/test/auth2/lib/UserNameTest.java b/src/test/java/us/kbase/test/auth2/lib/UserNameTest.java index 93875268..a181dd1e 100644 --- a/src/test/java/us/kbase/test/auth2/lib/UserNameTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/UserNameTest.java @@ -25,18 +25,18 @@ public void root() throws Exception { assertThat("incorrect username", un.getName(), is("***ROOT***")); assertThat("incorrect is root", un.isRoot(), is(true)); assertThat("incorrect toString", un.toString(), is("UserName [getName()=***ROOT***]")); - + final UserName un2 = UserName.ROOT; assertThat("incorrect username", un2.getName(), is("***ROOT***")); assertThat("incorrect is root", un2.isRoot(), is(true)); assertThat("incorrect toString", un2.toString(), is("UserName [getName()=***ROOT***]")); - + final NewUserName nun = NewUserName.ROOT; assertThat("incorrect username", nun.getName(), is("***ROOT***")); assertThat("incorrect is root", nun.isRoot(), is(true)); assertThat("incorrect toString", nun.toString(), is("NewUserName [getName()=***ROOT***]")); } - + @Test public void construct() throws Exception { final UserName un = new UserName("a8___nba9__"); @@ -44,7 +44,7 @@ public void construct() throws Exception { assertThat("incorrect is root", un.isRoot(), is(false)); assertThat("incorrect toString", un.toString(), is("UserName [getName()=a8___nba9__]")); } - + @Test public void constructNewUser() throws Exception { final NewUserName un = new NewUserName("a8_nba9"); diff --git a/src/test/java/us/kbase/test/auth2/lib/identity/RemoteIdentityTest.java b/src/test/java/us/kbase/test/auth2/lib/identity/RemoteIdentityTest.java index 91ba1601..b2abaeb7 100644 --- a/src/test/java/us/kbase/test/auth2/lib/identity/RemoteIdentityTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/identity/RemoteIdentityTest.java @@ -1,5 +1,6 @@ package us.kbase.test.auth2.lib.identity; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; @@ -9,6 +10,7 @@ import nl.jqno.equalsverifier.EqualsVerifier; import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentityID; @@ -20,9 +22,9 @@ public void remoteDetailsWithAllFields() throws Exception { assertThat("incorrect username", dets.getUsername(), is("user")); assertThat("incorrect fullname", dets.getFullname(), is("full")); assertThat("incorrect email", dets.getEmail(), is("email")); - assertThat("incorrect hashcode", dets.hashCode(), is(-1536596969)); + assertThat("incorrect mfa status", dets.getMfa(), is(MfaStatus.UNKNOWN)); assertThat("incorrect toString()", dets.toString(), - is("RemoteIdentityDetails [username=user, fullname=full, email=email]")); + is("RemoteIdentityDetails [username=user, fullname=full, email=email, mfa=UNKNOWN]")); } @Test @@ -31,17 +33,17 @@ public void remoteDetailsWithEmptyFields() throws Exception { assertThat("incorrect username", dets.getUsername(), is("user")); assertThat("incorrect fullname", dets.getFullname(), is((String) null)); assertThat("incorrect email", dets.getEmail(), is((String) null)); - assertThat("incorrect hashcode", dets.hashCode(), is(3629098)); + assertThat("incorrect mfa status", dets.getMfa(), is(MfaStatus.UNKNOWN)); assertThat("incorrect toString()", dets.toString(), - is("RemoteIdentityDetails [username=user, fullname=null, email=null]")); - + is("RemoteIdentityDetails [username=user, fullname=null, email=null, mfa=UNKNOWN]")); + final RemoteIdentityDetails dets2 = new RemoteIdentityDetails("user", null, null); assertThat("incorrect username", dets2.getUsername(), is("user")); assertThat("incorrect fullname", dets2.getFullname(), is((String) null)); assertThat("incorrect email", dets2.getEmail(), is((String) null)); - assertThat("incorrect hashcode", dets2.hashCode(), is(3629098)); + assertThat("incorrect mfa status", dets2.getMfa(), is(MfaStatus.UNKNOWN)); assertThat("incorrect toString()", dets2.toString(), - is("RemoteIdentityDetails [username=user, fullname=null, email=null]")); + is("RemoteIdentityDetails [username=user, fullname=null, email=null, mfa=UNKNOWN]")); } @Test @@ -73,7 +75,6 @@ public void remoteId() throws Exception { assertThat("incorrect unique id", id.getID(), is("5c7d96a3dd7a87850a2ef34087565a6e")); // check unique id again to check memoization doesn't change result assertThat("incorrect unique id", id.getID(), is("5c7d96a3dd7a87850a2ef34087565a6e")); - assertThat("incorrect hashcode", id.hashCode(), is(3118804)); assertThat("incorrect toString()", id.toString(), is("RemoteIdentityID [provider=foo, id=bar]")); } @@ -109,13 +110,13 @@ private void failCreateID(final String provider, final String id, final String e public void identity() throws Exception { final RemoteIdentityID id = new RemoteIdentityID("p", "i"); final RemoteIdentityDetails dets = new RemoteIdentityDetails("u", "f", "e"); + assertThat("incorrect mfa status", dets.getMfa(), is(MfaStatus.UNKNOWN)); final RemoteIdentity ri = new RemoteIdentity(id, dets); assertThat("incorrect id", ri.getRemoteID(), is(id)); assertThat("incorrect details", ri.getDetails(), is(dets)); - assertThat("incorrect hashcode", ri.hashCode(), is(4039350)); assertThat("incorrect toString()", ri.toString(), is("RemoteIdentity [remoteID=RemoteIdentityID [provider=p, id=i], " + - "details=RemoteIdentityDetails [username=u, fullname=f, email=e]]")); + "details=RemoteIdentityDetails [username=u, fullname=f, email=e, mfa=UNKNOWN]]")); } @Test @@ -206,4 +207,104 @@ public void compareToNullFails() throws Exception { assertThat("incorrect exception msg", e.getMessage(), is("other")); } } + + @Test + public void remoteDetailsWithMfaStatuses() throws Exception { + // Test USED status + final RemoteIdentityDetails detsUsed = new RemoteIdentityDetails("user", "full", "email", MfaStatus.USED); + assertThat("incorrect username", detsUsed.getUsername(), is("user")); + assertThat("incorrect fullname", detsUsed.getFullname(), is("full")); + assertThat("incorrect email", detsUsed.getEmail(), is("email")); + assertThat("incorrect mfa status", detsUsed.getMfa(), is(MfaStatus.USED)); + assertThat("toString should contain mfa status", detsUsed.toString(), containsString("mfa=USED")); + + // Test NOT_USED status + final RemoteIdentityDetails detsNotUsed = new RemoteIdentityDetails("user", "full", "email", MfaStatus.NOT_USED); + assertThat("incorrect mfa status", detsNotUsed.getMfa(), is(MfaStatus.NOT_USED)); + assertThat("toString should contain mfa status", detsNotUsed.toString(), containsString("mfa=NOT_USED")); + + // Test UNKNOWN status + final RemoteIdentityDetails detsUnknown = new RemoteIdentityDetails("user", "full", "email", MfaStatus.UNKNOWN); + assertThat("incorrect mfa status", detsUnknown.getMfa(), is(MfaStatus.UNKNOWN)); + assertThat("toString should contain mfa status", detsUnknown.toString(), containsString("mfa=UNKNOWN")); + } + + @Test + public void remoteDetailsMfaFailWithNullUser() throws Exception { + try { + new RemoteIdentityDetails(null, "full", "email", MfaStatus.USED); + fail("created bad details with mfa"); + } catch (IllegalArgumentException e) { + assertThat("incorrect exception msg", e.getMessage(), + is("username cannot be null or empty")); + } + } + + @Test + public void remoteDetailsMfaFailWithNullMfaStatus() throws Exception { + try { + new RemoteIdentityDetails("user", "full", "email", null); + fail("created details with null mfa status"); + } catch (NullPointerException e) { + assertThat("incorrect exception msg", e.getMessage(), is("mfa")); + } + } + + //TODO CODE move the mfa status tests into their own file in lib/token + + @Test + public void mfaStatusGetDescription() throws Exception { + assertThat("incorrect Used description", MfaStatus.USED.getDescription(), is("MFA used")); + assertThat("incorrect NotUsed description", MfaStatus.NOT_USED.getDescription(), is("MFA not used")); + assertThat("incorrect Unknown description", MfaStatus.UNKNOWN.getDescription(), is("MFA status unknown")); + } + + @Test + public void mfaStatusIDsAreStableForSerialization() throws Exception { + // These IDs are persisted to database via JSON serialization and must never change. + // Changing these values would break backwards compatibility with existing tokens + // and user data stored in MongoDB. + assertThat("USED ID must be stable", MfaStatus.USED.getID(), is("Used")); + assertThat("NOT_USED ID must be stable", MfaStatus.NOT_USED.getID(), is("NotUsed")); + assertThat("UNKNOWN ID must be stable", MfaStatus.UNKNOWN.getID(), is("Unknown")); + } + + @Test + public void mfaStatusFromIDValidValues() throws Exception { + assertThat("incorrect fromID for Used", MfaStatus.fromID("Used"), is(MfaStatus.USED)); + assertThat("incorrect fromID for NotUsed", MfaStatus.fromID("NotUsed"), is(MfaStatus.NOT_USED)); + assertThat("incorrect fromID for Unknown", MfaStatus.fromID("Unknown"), is(MfaStatus.UNKNOWN)); + } + + @Test + public void mfaStatusFromIDNullOrEmpty() throws Exception { + try { + MfaStatus.fromID(null); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat("correct exception message", e.getMessage(), is("Invalid MFA status: null")); + } + try { + MfaStatus.fromID(""); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat("correct exception message", e.getMessage(), is("Invalid MFA status: ")); + } + } + + @Test + public void mfaStatusFromIDUnrecognized() throws Exception { + try { + MfaStatus.fromID("INVALID"); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat("correct exception message", e.getMessage(), is("Invalid MFA status: INVALID")); + } + try { + MfaStatus.fromID("used"); + fail("expected exception"); + } catch (IllegalArgumentException e) { + assertThat("correct exception message", e.getMessage(), is("Invalid MFA status: used")); + } + } } diff --git a/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTempSessionDataTest.java b/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTempSessionDataTest.java index 6941afbf..bad8f5e1 100644 --- a/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTempSessionDataTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTempSessionDataTest.java @@ -15,6 +15,7 @@ import us.kbase.auth2.lib.exceptions.ErrorType; import us.kbase.auth2.lib.exceptions.NoSuchTokenException; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; import us.kbase.auth2.lib.identity.RemoteIdentityID; import us.kbase.auth2.lib.identity.RemoteIdentity; @@ -333,4 +334,70 @@ public void deleteTempDataFailNull() throws Exception { } } + @Test + public void storeAndGetLoginIdentsWithMfaUsed() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); // mongo truncates + final TemporarySessionData tsd = TemporarySessionData.create(id, now, now.plusSeconds(10)) + .login(set(REMOTE1, REMOTE2), MfaStatus.USED); + storage.storeTemporarySessionData(tsd, IncomingToken.hash("mfatest1")); + + final TemporarySessionData retrieved = storage.getTemporarySessionData( + new IncomingToken("mfatest1").getHashedToken()); + + assertThat("incorrect session data", retrieved, is( + TemporarySessionData.create(id, now, now.plusSeconds(10)) + .login(set(REMOTE1, REMOTE2), MfaStatus.USED))); + assertThat("incorrect mfa", retrieved.getMfa(), is(MfaStatus.USED)); + } + + @Test + public void storeAndGetLoginIdentsWithMfaNotUsed() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); // mongo truncates + final TemporarySessionData tsd = TemporarySessionData.create(id, now, now.plusSeconds(10)) + .login(set(REMOTE2), MfaStatus.NOT_USED); + storage.storeTemporarySessionData(tsd, IncomingToken.hash("mfatest2")); + + final TemporarySessionData retrieved = storage.getTemporarySessionData( + new IncomingToken("mfatest2").getHashedToken()); + + assertThat("incorrect session data", retrieved, is( + TemporarySessionData.create(id, now, now.plusSeconds(10)) + .login(set(REMOTE2), MfaStatus.NOT_USED))); + assertThat("incorrect mfa", retrieved.getMfa(), is(MfaStatus.NOT_USED)); + } + + @Test + public void storeAndGetLoginIdentsWithMfaUnknown() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); // mongo truncates + final TemporarySessionData tsd = TemporarySessionData.create(id, now, now.plusSeconds(10)) + .login(set(REMOTE1), MfaStatus.UNKNOWN); + storage.storeTemporarySessionData(tsd, IncomingToken.hash("mfatest3")); + + final TemporarySessionData retrieved = storage.getTemporarySessionData( + new IncomingToken("mfatest3").getHashedToken()); + + assertThat("incorrect session data", retrieved, is( + TemporarySessionData.create(id, now, now.plusSeconds(10)) + .login(set(REMOTE1), MfaStatus.UNKNOWN))); + assertThat("incorrect mfa", retrieved.getMfa(), is(MfaStatus.UNKNOWN)); + } + + @Test + public void storeAndGetLoginStartVerifyMfaUnknown() throws Exception { + // Verify that LOGINSTART operations default to MFA UNKNOWN + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); // mongo truncates + final TemporarySessionData tsd = TemporarySessionData.create(id, now, now.plusSeconds(10)) + .login("stateystate", "pkcecode"); + storage.storeTemporarySessionData(tsd, IncomingToken.hash("mfatest4")); + + final TemporarySessionData retrieved = storage.getTemporarySessionData( + new IncomingToken("mfatest4").getHashedToken()); + + assertThat("incorrect mfa", retrieved.getMfa(), is(MfaStatus.UNKNOWN)); + } + } diff --git a/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTokensTest.java b/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTokensTest.java index 4bfc6268..f10626c9 100644 --- a/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTokensTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageTokensTest.java @@ -18,6 +18,7 @@ import us.kbase.auth2.lib.TokenCreationContext; import us.kbase.auth2.lib.UserName; import us.kbase.auth2.lib.exceptions.NoSuchTokenException; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.token.IncomingHashedToken; import us.kbase.auth2.lib.token.IncomingToken; import us.kbase.auth2.lib.token.StoredToken; @@ -42,9 +43,10 @@ TokenType.LOGIN, id, new UserName("bar")) .withCustomContext("k1", "v1") .withCustomContext("k2", "v2") .build()) + .withMfa(MfaStatus.NOT_USED) .withTokenName(new TokenName("foo")).build(); storage.storeToken(store, "nJKFR6Xc4vzCeI3jT+FjlC9k5Q/qVw0zd0gi1erL8ew="); - + final StoredToken expected = StoredToken.getBuilder( TokenType.LOGIN, id, new UserName("bar")) .withLifeTime(now, now.plusSeconds(20)) @@ -56,11 +58,64 @@ TokenType.LOGIN, id, new UserName("bar")) .withCustomContext("k1", "v1") .withCustomContext("k2", "v2") .build()) + .withMfa(MfaStatus.NOT_USED) .withTokenName(new TokenName("foo")).build(); final StoredToken st = storage.getToken(new IncomingToken("sometoken").getHashedToken()); assertThat("incorrect token", st, is(expected)); } - + + @Test + public void storeAndGetWithMfaUsed() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + final StoredToken store = StoredToken.getBuilder( + TokenType.LOGIN, id, new UserName("bar")) + .withLifeTime(now, now.plusSeconds(20)) + .withContext(TokenCreationContext.getBuilder() + .withIpAddress(InetAddress.getByName("1.1.1.2")) + .build()) + .withMfa(MfaStatus.USED) + .withTokenName(new TokenName("mfaused")).build(); + storage.storeToken(store, new IncomingToken("mfausedtoken").getHashedToken().getTokenHash()); + + final StoredToken expected = StoredToken.getBuilder( + TokenType.LOGIN, id, new UserName("bar")) + .withLifeTime(now, now.plusSeconds(20)) + .withContext(TokenCreationContext.getBuilder() + .withIpAddress(InetAddress.getByName("1.1.1.2")) + .build()) + .withMfa(MfaStatus.USED) + .withTokenName(new TokenName("mfaused")).build(); + final StoredToken st = storage.getToken(new IncomingToken("mfausedtoken").getHashedToken()); + assertThat("incorrect token", st, is(expected)); + } + + @Test + public void storeAndGetWithMfaUnknown() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + final StoredToken store = StoredToken.getBuilder( + TokenType.LOGIN, id, new UserName("bar")) + .withLifeTime(now, now.plusSeconds(20)) + .withContext(TokenCreationContext.getBuilder() + .withIpAddress(InetAddress.getByName("1.1.1.2")) + .build()) + .withMfa(MfaStatus.UNKNOWN) + .withTokenName(new TokenName("mfaunknown")).build(); + storage.storeToken(store, new IncomingToken("mfaunknowntoken").getHashedToken().getTokenHash()); + + final StoredToken expected = StoredToken.getBuilder( + TokenType.LOGIN, id, new UserName("bar")) + .withLifeTime(now, now.plusSeconds(20)) + .withContext(TokenCreationContext.getBuilder() + .withIpAddress(InetAddress.getByName("1.1.1.2")) + .build()) + .withMfa(MfaStatus.UNKNOWN) + .withTokenName(new TokenName("mfaunknown")).build(); + final StoredToken st = storage.getToken(new IncomingToken("mfaunknowntoken").getHashedToken()); + assertThat("incorrect token", st, is(expected)); + } + @Test public void storeAndGetWithLocalhost() throws Exception { final UUID id = UUID.randomUUID(); @@ -126,6 +181,31 @@ TokenType.LOGIN, id, new UserName("bar")) assertThat("incorrect token", st, is(expected)); } + /** + * Tests backwards compatibility with tokens created before the MFA field was added. + * Uses $unset to simulate legacy database records without the MFA field. + */ + @Test + public void getWithNullMfaBackwardsCompatibility() throws Exception { + final UUID id = UUID.randomUUID(); + final Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); // mongo truncates + final StoredToken ht = StoredToken.getBuilder( + TokenType.LOGIN, id, new UserName("bar")) + .withLifeTime(now, now.plusSeconds(10)).build(); + storage.storeToken(ht, "nJKFR6Xc4vzCeI3jT+FjlC9k5Q/qVw0zd0gi1erL8ew="); + + // Remove the MFA field to simulate old database records + db.getCollection("tokens").updateOne(new Document("id", id.toString()), + new Document("$unset", new Document("mfa", ""))); + + final StoredToken expected = StoredToken.getBuilder( + TokenType.LOGIN, id, new UserName("bar")) + .withLifeTime(now, now.plusSeconds(10)).build(); + + final StoredToken st = storage.getToken(new IncomingToken("sometoken").getHashedToken()); + assertThat("incorrect token", st, is(expected)); + } + @Test public void storeTokenFailNull() throws Exception { final StoredToken st = StoredToken.getBuilder( diff --git a/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageUserCreateGetTest.java b/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageUserCreateGetTest.java index 5abe138c..95a4d2a1 100644 --- a/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageUserCreateGetTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/storage/mongo/MongoStorageUserCreateGetTest.java @@ -13,6 +13,7 @@ import java.util.Optional; import java.util.UUID; +import org.bson.Document; import org.junit.Test; import com.google.common.collect.ImmutableMap; @@ -31,6 +32,7 @@ import us.kbase.auth2.lib.exceptions.NoSuchUserException; import us.kbase.auth2.lib.exceptions.UserExistsException; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentityID; import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.storage.exceptions.AuthStorageException; @@ -56,6 +58,18 @@ public class MongoStorageUserCreateGetTest extends MongoStorageTester { private static final RemoteIdentity REMOTE2 = new RemoteIdentity( new RemoteIdentityID("prov", "bar2"), new RemoteIdentityDetails("user2", "full2", "email2")); + + private static final RemoteIdentity REMOTE_MFA_TRUE = new RemoteIdentity( + new RemoteIdentityID("orcid", "0000-0001-1234-5678"), + new RemoteIdentityDetails("orciduser", "ORCID User", "orcid@example.com", MfaStatus.USED)); + + private static final RemoteIdentity REMOTE_MFA_FALSE = new RemoteIdentity( + new RemoteIdentityID("orcid", "0000-0001-1234-9999"), + new RemoteIdentityDetails("orciduser2", "ORCID User 2", "orcid2@example.com", MfaStatus.NOT_USED)); + + private static final RemoteIdentity REMOTE_MFA_NULL = new RemoteIdentity( + new RemoteIdentityID("orcid", "0000-0001-1234-0000"), + new RemoteIdentityDetails("orciduser3", "ORCID User 3", "orcid3@example.com", MfaStatus.UNKNOWN)); @Test public void createGetLocalUserMinimal() throws Exception { @@ -593,4 +607,93 @@ public void getUserAndUpdateRemoteId() throws Exception { assertThat("incorrect email", au.getEmail(), is(new EmailAddress("e@g1.com"))); // ok, thats enough } + + @Test + public void createUserWithMfaTrue() throws Exception { + testCreateUserWithMfaAndVerifyStorage("mfauser", "MFA User", "mfa@example.com", REMOTE_MFA_TRUE); + } + + @Test + public void createUserWithMfaFalse() throws Exception { + testCreateUserWithMfaAndVerifyStorage("nomfauser", "No MFA User", "nomfa@example.com", REMOTE_MFA_FALSE); + } + + @Test + public void createUserWithMfaNull() throws Exception { + testCreateUserWithMfaAndVerifyStorage("unknownmfauser", "Unknown MFA User", "unknownmfa@example.com", REMOTE_MFA_NULL); + } + + private void testCreateUserWithMfaAndVerifyStorage(final String userName, final String displayName, + final String email, final RemoteIdentity remoteIdentity) throws Exception { + storage.createUser(NewUser.getBuilder( + new UserName(userName), UID, new DisplayName(displayName), NOW, remoteIdentity) + .withEmailAddress(new EmailAddress(email)) + .build()); + + final AuthUser u = storage.getUser(new UserName(userName)); + + // MFA is not persisted on identities - when loaded from DB, it will always be UNKNOWN + final RemoteIdentity expectedIdentity = new RemoteIdentity( + remoteIdentity.getRemoteID(), + new RemoteIdentityDetails( + remoteIdentity.getDetails().getUsername(), + remoteIdentity.getDetails().getFullname(), + remoteIdentity.getDetails().getEmail(), + MfaStatus.UNKNOWN)); + + assertThat("incorrect identities", u.getIdentities(), is(set(expectedIdentity))); + assertThat("incorrect username", u.getUserName(), is(new UserName(userName))); + assertThat("incorrect display name", u.getDisplayName(), is(new DisplayName(displayName))); + assertThat("incorrect email", u.getEmail(), is(new EmailAddress(email))); + } + + @Test + public void getUserByRemoteIdWithMfa() throws Exception { + storage.createUser(NewUser.getBuilder( + new UserName("mfauser"), UID, new DisplayName("MFA User"), NOW, REMOTE_MFA_TRUE) + .withEmailAddress(new EmailAddress("mfa@example.com")) + .build()); + + final AuthUser u = storage.getUser(REMOTE_MFA_TRUE).get(); + + // MFA is not persisted on identities - when loaded from DB, it will always be UNKNOWN + final RemoteIdentity expectedIdentity = new RemoteIdentity( + new RemoteIdentityID("orcid", "0000-0001-1234-5678"), + new RemoteIdentityDetails("orciduser", "ORCID User", "orcid@example.com", MfaStatus.UNKNOWN)); + + assertThat("incorrect identities", u.getIdentities(), is(set(expectedIdentity))); + assertThat("incorrect username", u.getUserName(), is(new UserName("mfauser"))); + assertThat("incorrect display name", u.getDisplayName(), is(new DisplayName("MFA User"))); + assertThat("incorrect email", u.getEmail(), is(new EmailAddress("mfa@example.com"))); + assertThat("incorrect is disabled", u.isDisabled(), is(false)); + assertThat("incorrect is local", u.isLocal(), is(false)); + } + + @Test + public void getUserByRemoteIdWithoutMfaField() throws Exception { + /* Tests backwards compatibility with old identity documents that don't have an MFA field + * in the db. + */ + storage.createUser(NewUser.getBuilder( + new UserName("olduser"), UID, new DisplayName("Old User"), NOW, REMOTE1) + .withEmailAddress(new EmailAddress("old@example.com")) + .build()); + + // Remove the MFA field from identity to simulate old database records + db.getCollection("users").updateOne( + new Document("user", "olduser"), + new Document("$unset", new Document("idents.mfa", ""))); + + final AuthUser u = storage.getUser(REMOTE1).get(); + + // When MFA field is missing, it should default to UNKNOWN + final RemoteIdentity expectedIdentity = new RemoteIdentity( + new RemoteIdentityID("prov", "bar1"), + new RemoteIdentityDetails("user1", "full1", "email1", MfaStatus.UNKNOWN)); + + assertThat("incorrect identities", u.getIdentities(), is(set(expectedIdentity))); + assertThat("incorrect username", u.getUserName(), is(new UserName("olduser"))); + assertThat("incorrect display name", u.getDisplayName(), is(new DisplayName("Old User"))); + assertThat("incorrect email", u.getEmail(), is(new EmailAddress("old@example.com"))); + } } diff --git a/src/test/java/us/kbase/test/auth2/lib/token/TokenTest.java b/src/test/java/us/kbase/test/auth2/lib/token/TokenTest.java index 035fb0dd..d7e857a8 100644 --- a/src/test/java/us/kbase/test/auth2/lib/token/TokenTest.java +++ b/src/test/java/us/kbase/test/auth2/lib/token/TokenTest.java @@ -37,7 +37,8 @@ public class TokenTest { @Test public void equalsStoredToken() throws Exception { - EqualsVerifier.forClass(StoredToken.class).usingGetClass().verify(); + EqualsVerifier.forClass(StoredToken.class).usingGetClass() + .withNonnullFields("mfa").verify(); } @Test @@ -161,7 +162,7 @@ private void failCreateTemporaryToken( @Test public void storedTokenMSLifetime() throws Exception { - + final UUID id = UUID.randomUUID(); final StoredToken ht = StoredToken.getBuilder(TokenType.LOGIN, id, new UserName("whee")) .withLifeTime(Instant.ofEpochMilli(1000), 4000).build(); @@ -175,6 +176,7 @@ public void storedTokenMSLifetime() throws Exception { is(Instant.ofEpochMilli(5000))); assertThat("incorrect context", ht.getContext(), is(TokenCreationContext.getBuilder().build())); + assertThat("incorrect default mfa status", ht.getMfa(), is(us.kbase.auth2.lib.identity.MfaStatus.UNKNOWN)); } @Test @@ -183,6 +185,7 @@ public void storedTokenExpDateAndNameAndContext() throws Exception { final StoredToken ht2 = StoredToken.getBuilder(TokenType.DEV, id2, new UserName("whee2")) .withLifeTime(Instant.ofEpochMilli(27000), Instant.ofEpochMilli(42000)) .withContext(TokenCreationContext.getBuilder().withNullableDevice("d").build()) + .withMfa(us.kbase.auth2.lib.identity.MfaStatus.USED) .withTokenName(new TokenName("ugh")).build(); assertThat("incorrect token type", ht2.getTokenType(), is(TokenType.DEV)); assertThat("incorrect token name", ht2.getTokenName(), @@ -195,6 +198,7 @@ public void storedTokenExpDateAndNameAndContext() throws Exception { is(Instant.ofEpochMilli(42000))); assertThat("incorrect context", ht2.getContext(), is(TokenCreationContext.getBuilder().withNullableDevice("d").build())); + assertThat("incorrect mfa status", ht2.getMfa(), is(us.kbase.auth2.lib.identity.MfaStatus.USED)); } @Test diff --git a/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java b/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java index f061dbda..490c6c59 100644 --- a/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java +++ b/src/test/java/us/kbase/test/auth2/providers/OrcIDIdentityProviderTest.java @@ -1,6 +1,8 @@ package us.kbase.test.auth2.providers; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; import static org.junit.Assert.assertThat; import static org.junit.Assert.fail; import static us.kbase.test.auth2.TestCommon.set; @@ -35,6 +37,7 @@ import us.kbase.auth2.lib.identity.IdentityProviderConfig; import us.kbase.auth2.lib.identity.RemoteIdentity; import us.kbase.auth2.lib.identity.RemoteIdentityDetails; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.identity.RemoteIdentityID; import us.kbase.auth2.lib.identity.IdentityProviderConfig.IdentityProviderConfigurationException; import us.kbase.auth2.providers.OrcIDIdentityProviderFactory; @@ -119,23 +122,23 @@ public void simpleOperationsWithConfigurator() throws Exception { assertThat("incorrect environments", oip.getEnvironments(), is(set("myenv"))); assertThat("incorrect login url", oip.getLoginURI("foo3", "pkce", false, null), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo3&redirect_uri=https%3A%2F%2Fologinredir.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect link url", oip.getLoginURI("foo4", "pkce", true, null), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo4&redirect_uri=https%3A%2F%2Folinkredir.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect login url", oip.getLoginURI("foo3", "pkce", false, "myenv"), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo3&redirect_uri=https%3A%2F%2Fmyologinred.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect link url", oip.getLoginURI("foo4", "pkce", true, "myenv"), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo4&redirect_uri=https%3A%2F%2Fmyolinkred.com" + "&response_type=code&client_id=ofoo"))); } @@ -148,23 +151,23 @@ public void simpleOperationsWithoutConfigurator() throws Exception { assertThat("incorrect environments", oip.getEnvironments(), is(set("myenv"))); assertThat("incorrect login url", oip.getLoginURI("foo5", "pkce", false, null), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo5&redirect_uri=https%3A%2F%2Fologinredir.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect link url", oip.getLoginURI("foo6", "pkce", true, null), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo6&redirect_uri=https%3A%2F%2Folinkredir.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect login url", oip.getLoginURI("foo3", "pkce", false, "myenv"), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo3&redirect_uri=https%3A%2F%2Fmyologinred.com" + "&response_type=code&client_id=ofoo"))); assertThat("incorrect link url", oip.getLoginURI("foo4", "pkce", true, "myenv"), is(new URI("https://ologin.com/oauth/authorize?" + - "scope=%2Fauthenticate" + + "scope=openid+%2Fauthenticate" + "&state=foo4&redirect_uri=https%3A%2F%2Fmyolinkred.com" + "&response_type=code&client_id=ofoo"))); @@ -265,15 +268,15 @@ public void returnsIllegalAuthtokenResponse() throws Exception { final IdentityRetrievalException e = new IdentityRetrievalException("No access token was returned by OrcID"); - setUpCallAuthToken(acode, null, redir, cliid, clisec, "name", "fake ID"); + setUpCallAuthTokenWithJWT(acode, null, redir, cliid, clisec, "name", "fake ID", createJWTWithoutAmr("fake ID")); failGetIdentities(idp, acode, "pkce", false, e); - setUpCallAuthToken(acode, "\t ", redir, cliid, clisec, "name", "fake ID"); + setUpCallAuthTokenWithJWT(acode, "\t ", redir, cliid, clisec, "name", "fake ID", createJWTWithoutAmr("fake ID")); failGetIdentities(idp, acode, "pkce", false, e); - setUpCallAuthToken(acode, "fake token", redir, cliid, clisec, "my name", null); + setUpCallAuthTokenWithJWT(acode, "fake token", redir, cliid, clisec, "my name", null, createJWTWithoutAmr("fake ID")); failGetIdentities(idp, acode, "pkce", false, new IdentityRetrievalException( "No id was returned by OrcID")); - setUpCallAuthToken(acode, "fake token", redir, cliid, clisec, "my name", " \t \n "); + setUpCallAuthTokenWithJWT(acode, "fake token", redir, cliid, clisec, "my name", " \t \n ", createJWTWithoutAmr("fake ID")); failGetIdentities(idp, acode, "pkce", false, new IdentityRetrievalException( "No id was returned by OrcID")); } @@ -334,43 +337,43 @@ public void returnsBadResponseIdentity() throws Exception { final String authtoken = "bartoken"; final String orcID = "0000-0001-1234-5678"; - setUpCallAuthToken(authCode, authtoken, redir, cliid, clisec, "my name", orcID); + setUpCallAuthTokenWithJWT(authCode, authtoken, redir, cliid, clisec, "my name", orcID, createJWTWithoutAmr(orcID)); setupCallID(authtoken, orcID, APP_JSON, 200, "bleah"); failGetIdentities(idp, authCode, "pkce", false, new IdentityRetrievalException( "Unable to parse response from OrcID service.")); - setUpCallAuthToken(authCode, authtoken, redir, cliid, clisec, "my name", orcID); + setUpCallAuthTokenWithJWT(authCode, authtoken, redir, cliid, clisec, "my name", orcID, createJWTWithoutAmr(orcID)); setupCallID(authtoken, orcID, "text/html", 200, MAPPER.writeValueAsString( map("id", "id1", "displayName", "dispname1", "emails", Arrays.asList( map("value", "email1"))))); failGetIdentities(idp, authCode, "pkce", false, new IdentityRetrievalException( "Unable to parse response from OrcID service.")); - setUpCallAuthToken(authCode, authtoken, redir, cliid, clisec, "my name", orcID); + setUpCallAuthTokenWithJWT(authCode, authtoken, redir, cliid, clisec, "my name", orcID, createJWTWithoutAmr(orcID)); setupCallID(authtoken, orcID, APP_JSON, 500, STRING1000); failGetIdentities(idp, authCode, "pkce", false, new IdentityRetrievalException( "Got unexpected HTTP code and unparseable " + "response from OrcID service: 500. Response: " + STRING1000)); - setUpCallAuthToken(authCode, authtoken, redir, cliid, clisec, "my name", orcID); + setUpCallAuthTokenWithJWT(authCode, authtoken, redir, cliid, clisec, "my name", orcID, createJWTWithoutAmr(orcID)); setupCallID(authtoken, orcID, APP_JSON, 500, STRING1001); failGetIdentities(idp, authCode, "pkce", false, new IdentityRetrievalException( "Got unexpected HTTP code and unparseable " + "response from OrcID service: 500. Truncated response: " + STRING1000)); - setUpCallAuthToken(authCode, authtoken, redir, cliid, clisec, "my name", orcID); + setUpCallAuthTokenWithJWT(authCode, authtoken, redir, cliid, clisec, "my name", orcID, createJWTWithoutAmr(orcID)); setupCallID(authtoken, orcID, APP_JSON, 500, null); failGetIdentities(idp, authCode, "pkce", false, new IdentityRetrievalException( "Got unexpected HTTP code with no response " + "body from OrcID service: 500.")); - setUpCallAuthToken(authCode, authtoken, redir, cliid, clisec, "my name", orcID); + setUpCallAuthTokenWithJWT(authCode, authtoken, redir, cliid, clisec, "my name", orcID, createJWTWithoutAmr(orcID)); setupCallID(authtoken, orcID, APP_JSON, 500, "{}"); failGetIdentities(idp, authCode, "pkce", false, new IdentityRetrievalException( "Got unexpected HTTP code with no error in " + "the response body from OrcID service: 500.")); - setUpCallAuthToken(authCode, authtoken, redir, cliid, clisec, "my name", orcID); + setUpCallAuthTokenWithJWT(authCode, authtoken, redir, cliid, clisec, "my name", orcID, createJWTWithoutAmr(orcID)); setupCallID(authtoken, orcID, APP_JSON, 401, MAPPER.writeValueAsString( map("error", "whee!", "error_description", "whoo!"))); failGetIdentities(idp, authCode, "pkce", false, new IdentityRetrievalException( @@ -396,8 +399,8 @@ private void getIdentityWithLoginURL(final String email, final Map rids = idp.getIdentities(authCode, "pkce", false, null); assertThat("incorrect number of idents", rids.size(), is(1)); @@ -414,15 +417,15 @@ public void getIdentityWithLoginURLAndEnvironment() throws Exception { final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); final String orcID = "0000-0001-1234-5678"; - setUpCallAuthToken(authCode, "footoken3", "https://lo.com", - idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID); + setUpCallAuthTokenWithJWT(authCode, "footoken3", "https://lo.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, createJWTWithoutAmr(orcID)); setupCallID("footoken3", orcID, APP_JSON, 200, MAPPER.writeValueAsString( map("email", Arrays.asList(map("email", "email7"))))); final Set rids = idp.getIdentities(authCode, "pkce", false, "e3"); assertThat("incorrect number of idents", rids.size(), is(1)); final Set expected = new HashSet<>(); expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), - new RemoteIdentityDetails(orcID, "My name", "email7"))); + new RemoteIdentityDetails(orcID, "My name", "email7", MfaStatus.UNKNOWN))); assertThat("incorrect ident set", rids, is(expected)); } @@ -452,9 +455,9 @@ private void getIdentityWithLinkURL(final String email, final Map rids = idp.getIdentities(authCode, "pkce", true, null); @@ -481,16 +484,195 @@ public void getIdentityWithLinkURLAndEnvironment() throws Exception { final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); final String orcID = "0000-0001-1234-5678"; - setUpCallAuthToken(authCode, "footoken2", "https://li.com", + setUpCallAuthTokenWithJWT(authCode, "footoken2", "https://li.com", idconfig.getClientID(), idconfig.getClientSecret(), - null, orcID); + null, orcID, createJWTWithoutAmr(orcID)); setupCallID("footoken2", orcID, APP_JSON, 200, MAPPER.writeValueAsString( map("email", Arrays.asList(map("email", "email4"))))); final Set rids = idp.getIdentities(authCode, "pkce", true, "e3"); assertThat("incorrect number of idents", rids.size(), is(1)); final Set expected = new HashSet<>(); expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), - new RemoteIdentityDetails(orcID, null, "email4"))); + new RemoteIdentityDetails(orcID, null, "email4", MfaStatus.UNKNOWN))); + assertThat("incorrect ident set", rids, is(expected)); + } + + /** Tests MFA detection when AMR array contains "mfa". */ + @Test + public void getIdentityArrayAmrWithMfa() throws Exception { + final String authCode = "authcodeWithMfa"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + final String jwt = createValidJWTWithMfa(orcID, true); + + setUpCallAuthTokenWithJWT(authCode, "footoken3", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt); + setupCallID("footoken3", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "mfa@test.com"))))); + final Set rids = idp.getIdentities(authCode, "pkce", false, null); + assertThat("incorrect number of idents", rids.size(), is(1)); + final Set expected = new HashSet<>(); + expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), + new RemoteIdentityDetails(orcID, "My name", "mfa@test.com", MfaStatus.USED))); + assertThat("incorrect ident set", rids, is(expected)); + } + + /** Tests MFA detection when AMR array does not contain "mfa". */ + @Test + public void getIdentityArrayAmrNoMfa() throws Exception { + final String authCode = "authcodeNoMfa"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + final String jwt = createValidJWTWithMfa(orcID, false); + + setUpCallAuthTokenWithJWT(authCode, "footoken4", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt); + setupCallID("footoken4", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "nomfa@test.com"))))); + final Set rids = idp.getIdentities(authCode, "pkce", false, null); + assertThat("incorrect number of idents", rids.size(), is(1)); + final Set expected = new HashSet<>(); + expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), + new RemoteIdentityDetails(orcID, "My name", "nomfa@test.com", MfaStatus.NOT_USED))); + assertThat("incorrect ident set", rids, is(expected)); + } + + /** Tests non-member ORCID accounts without JWTs default to UNKNOWN MFA. */ + @Test + public void getIdentityWithNoJWT() throws Exception { + final String authCode = "authcodeNoJWT"; + final IdentityProviderConfig idconfig = IdentityProviderConfig.getBuilder( + OrcIDIdentityProviderFactory.class.getName(), + new URL("http://localhost:" + mockClientAndServer.getPort()), + new URL("http://localhost:" + mockClientAndServer.getPort()), + "ofoo", + "obar", + new URL("https://ologinredir.com"), + new URL("https://olinkredir.com")) + .withCustomConfiguration("orcid-mfa-enabled", "false") + .build(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + setUpCallAuthToken(authCode, "footoken5", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID); + setupCallID("footoken5", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "nojwt@test.com"))))); + + // MFA checking disabled - no JWT expected, MFA status should be UNKNOWN + final Set rids = idp.getIdentities(authCode, "pkce", false, null); + assertThat("incorrect number of idents", rids.size(), is(1)); + final Set expected = new HashSet<>(); + expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), + new RemoteIdentityDetails(orcID, "My name", "nojwt@test.com", MfaStatus.UNKNOWN))); + assertThat("incorrect ident set", rids, is(expected)); + } + + @Test + public void getIdentityWithMissingJWT() throws Exception { + final String authCode = "authcodeMissingJWT"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + // MFA checking enabled but no JWT provided in response + setUpCallAuthTokenWithJWT(authCode, "footokenNoJWT", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, null); + + try { + idp.getIdentities(authCode, "pkce", false, null); + fail("Expected IdentityRetrievalException"); + } catch (IdentityRetrievalException e) { + assertThat("incorrect exception message", e.getMessage(), + containsString("No JWT token provided by ORCID. For non-member API applications, " + + "set orcid-mfa-enabled=false in provider configuration")); + } + } + + @Test + public void getIdentityWithEmptyStringJWT() throws Exception { + final String authCode = "authcodeEmptyStringJWT"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + // MFA checking enabled but empty/whitespace JWT provided in response + setUpCallAuthTokenWithJWT(authCode, "footokenEmptyStringJWT", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, " \t \n "); + + try { + idp.getIdentities(authCode, "pkce", false, null); + fail("Expected IdentityRetrievalException"); + } catch (IdentityRetrievalException e) { + assertThat("incorrect exception message", e.getMessage(), + containsString("No JWT token provided by ORCID. For non-member API applications, " + + "set orcid-mfa-enabled=false in provider configuration")); + } + } + + @Test + public void getIdentityWithInvalidJWT() throws Exception { + final String authCode = "authcodeInvalidJWT"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + final String invalidJWT = "invalid.jwt.token"; + + setUpCallAuthTokenWithJWT(authCode, "footoken6", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, invalidJWT); + setupCallID("footoken6", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "invalid@test.com"))))); + + try { + idp.getIdentities(authCode, "pkce", false, null); + fail("Expected IdentityRetrievalException"); + } catch (IdentityRetrievalException e) { + assertThat("incorrect exception message", e.getMessage(), + containsString("Unable to parse JWT payload from ORCID")); + } + } + + /** Tests AMR claim as string (ORCID allows string or array). */ + @Test + public void getIdentityStringAmrWithMfa() throws Exception { + final String authCode = "authcodeStringAmr"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + final String jwt = createJWTWithStringAmr(orcID, "mfa"); + + setUpCallAuthTokenWithJWT(authCode, "footoken7", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt); + setupCallID("footoken7", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "stringmfa@test.com"))))); + final Set rids = idp.getIdentities(authCode, "pkce", false, null); + assertThat("incorrect number of idents", rids.size(), is(1)); + final Set expected = new HashSet<>(); + expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), + new RemoteIdentityDetails(orcID, "My name", "stringmfa@test.com", MfaStatus.USED))); + assertThat("incorrect ident set", rids, is(expected)); + } + + /** Tests AMR claim as string without "mfa" value. */ + @Test + public void getIdentityStringAmrNoMfa() throws Exception { + final String authCode = "authcodeStringAmrNoMfa"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + final String jwt = createJWTWithStringAmr(orcID, "pwd"); + + setUpCallAuthTokenWithJWT(authCode, "footoken8", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt); + setupCallID("footoken8", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "stringnomfa@test.com"))))); + final Set rids = idp.getIdentities(authCode, "pkce", false, null); + assertThat("incorrect number of idents", rids.size(), is(1)); + final Set expected = new HashSet<>(); + expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), + new RemoteIdentityDetails(orcID, "My name", "stringnomfa@test.com", MfaStatus.NOT_USED))); assertThat("incorrect ident set", rids, is(expected)); } @@ -591,4 +773,279 @@ private Map map(final Object... entries) { } return ret; } + + private void setUpCallAuthTokenWithJWT( + final String authCode, + final String authtoken, + final String redirect, + final String clientID, + final String clientSecret, + final String name, + final String orcID, + final String jwt) + throws Exception { + mockClientAndServer.when( + new HttpRequest() + .withMethod("POST") + .withPath("/oauth/token") + .withHeader(ACCEPT, APP_JSON) + .withBody(new ParameterBody( + new Parameter("code", authCode), + new Parameter("grant_type", "authorization_code"), + new Parameter("redirect_uri", redirect), + new Parameter("client_id", clientID), + new Parameter("client_secret", clientSecret)) + ), + Times.exactly(1) + ).respond( + new HttpResponse() + .withStatusCode(200) + .withHeader(CONTENT_TYPE, APP_JSON) + .withBody(MAPPER.writeValueAsString(map( + "access_token", authtoken, + "name", name, + "orcid", orcID, + "id_token", jwt + ))) + ); + } + + private String createValidJWTWithMfa(final String orcID, final boolean usedMfa) { + final String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + final String payload = usedMfa ? + "{\"sub\":\"" + orcID + "\",\"amr\":[\"mfa\",\"pwd\"]}" : + "{\"sub\":\"" + orcID + "\",\"amr\":[\"pwd\"]}"; + final String encodedHeader = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes()); + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes()); + return encodedHeader + "." + encodedPayload + ".signature"; + } + + private String createJWTWithStringAmr(final String orcID, final String amrValue) { + final String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + final String payload = "{\"sub\":\"" + orcID + "\",\"amr\":\"" + amrValue + "\"}"; + final String encodedHeader = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes()); + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes()); + return encodedHeader + "." + encodedPayload + ".signature"; + } + + /** Tests JWT with 2 parts instead of 3 is rejected. */ + @Test + public void getIdentityWithMalformedJWT() throws Exception { + final String authCode = "authcodeMalformed"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + // Test JWT with only 2 parts + final String invalidJWT = "header.payload"; + setUpCallAuthTokenWithJWT(authCode, "footoken8", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, invalidJWT); + setupCallID("footoken8", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "malformed@test.com"))))); + + try { + idp.getIdentities(authCode, "pkce", false, null); + fail("Expected IdentityRetrievalException"); + } catch (IdentityRetrievalException e) { + assertThat("incorrect exception message", e.getMessage(), + containsString("Invalid JWT format from ORCID: expected 3 parts, got 2")); + } + } + + @Test + public void getIdentityWithInvalidBase64JWT() throws Exception { + final String authCode = "authcodeInvalidB64"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + // JWT with invalid base64 in payload + final String invalidJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid==base64!!.signature"; + setUpCallAuthTokenWithJWT(authCode, "footoken9", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, invalidJWT); + setupCallID("footoken9", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "invalidb64@test.com"))))); + + try { + idp.getIdentities(authCode, "pkce", false, null); + fail("Expected IdentityRetrievalException"); + } catch (IdentityRetrievalException e) { + assertThat("incorrect exception message", e.getMessage(), + containsString("Unable to decode JWT from ORCID")); + } + } + + @Test + public void getIdentityWithMalformedJSONJWT() throws Exception { + final String authCode = "authcodeMalformedJSON"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + // JWT with invalid JSON in payload + final String invalidJSON = "{\"sub\":\"" + orcID + "\",\"amr\":}"; + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(invalidJSON.getBytes()); + final String invalidJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + encodedPayload + ".signature"; + + setUpCallAuthTokenWithJWT(authCode, "footoken10", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, invalidJWT); + setupCallID("footoken10", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "malformedjson@test.com"))))); + + try { + idp.getIdentities(authCode, "pkce", false, null); + fail("Expected IdentityRetrievalException"); + } catch (IdentityRetrievalException e) { + assertThat("incorrect exception message", e.getMessage(), + containsString("Unable to parse JWT payload from ORCID")); + } + } + + @Test + public void getIdentityWithEmptyAmrJWT() throws Exception { + final String authCode = "authcodeEmptyAmr"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + final String jwt = createJWTWithEmptyAmr(orcID); + + setUpCallAuthTokenWithJWT(authCode, "footoken11", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt); + setupCallID("footoken11", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "emptyamr@test.com"))))); + final Set rids = idp.getIdentities(authCode, "pkce", false, null); + assertThat("incorrect number of idents", rids.size(), is(1)); + final Set expected = new HashSet<>(); + expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), + new RemoteIdentityDetails(orcID, "My name", "emptyamr@test.com", MfaStatus.NOT_USED))); + assertThat("incorrect ident set", rids, is(expected)); + } + + @Test + public void getIdentityWithNoAmrClaimJWT() throws Exception { + final String authCode = "authcodeNoAmr"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + final String jwt = createJWTWithoutAmr(orcID); + + setUpCallAuthTokenWithJWT(authCode, "footoken12", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt); + setupCallID("footoken12", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "noamr@test.com"))))); + final Set rids = idp.getIdentities(authCode, "pkce", false, null); + assertThat("incorrect number of idents", rids.size(), is(1)); + final Set expected = new HashSet<>(); + expected.add(new RemoteIdentity(new RemoteIdentityID(ORCID, orcID), + new RemoteIdentityDetails(orcID, "My name", "noamr@test.com", MfaStatus.UNKNOWN))); + assertThat("incorrect ident set", rids, is(expected)); + } + + private String createJWTWithEmptyAmr(final String orcID) { + final String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + final String payload = "{\"sub\":\"" + orcID + "\",\"amr\":[]}"; + final String encodedHeader = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes()); + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes()); + return encodedHeader + "." + encodedPayload + ".signature"; + } + + private String createJWTWithoutAmr(final String orcID) { + final String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + final String payload = "{\"sub\":\"" + orcID + "\",\"iss\":\"https://orcid.org\"}"; + final String encodedHeader = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes()); + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes()); + return encodedHeader + "." + encodedPayload + ".signature"; + } + + @Test + public void getIdentityWithInvalidAmrTypeJWT() throws Exception { + final String authCode = "authcodeInvalidAmrType"; + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + final String orcID = "0000-0001-1234-5678"; + + // JWT with AMR claim as object (not array or string) - unexpected format + final String jwt = createJWTWithObjectAmr(orcID); + + setUpCallAuthTokenWithJWT(authCode, "footokenInvalidAmr", "https://ologinredir.com", + idconfig.getClientID(), idconfig.getClientSecret(), " My name ", orcID, jwt); + setupCallID("footokenInvalidAmr", orcID, APP_JSON, 200, MAPPER.writeValueAsString( + map("email", Arrays.asList(map("email", "invalidamr@test.com"))))); + + try { + idp.getIdentities(authCode, "pkce", false, null); + fail("Expected IdentityRetrievalException"); + } catch (IdentityRetrievalException e) { + assertThat("incorrect exception message", e.getMessage(), + containsString("AMR claim from ORCID in unexpected format")); + } + } + + private String createJWTWithObjectAmr(final String orcID) { + final String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}"; + // AMR as object instead of array or string - this is an invalid/unexpected format + final String payload = "{\"sub\":\"" + orcID + "\",\"amr\":{\"method\":\"mfa\"}}"; + final String encodedHeader = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(header.getBytes()); + final String encodedPayload = java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes()); + return encodedHeader + "." + encodedPayload + ".signature"; + } + + @Test + public void getLoginURIWithMfaEnabled() throws Exception { + final IdentityProviderConfig idconfig = getTestIDConfig(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + + final URI loginURI = idp.getLoginURI("mystate123", "pkce_challenge", false, null); + + assertThat("incorrect URI host", loginURI.getHost(), is("localhost")); + assertThat("incorrect URI path", loginURI.getPath(), is("/oauth/authorize")); + assertThat("incorrect query string", loginURI.getQuery(), + containsString("scope=openid+/authenticate")); + assertThat("incorrect query string", loginURI.getQuery(), + containsString("state=mystate123")); + assertThat("incorrect query string", loginURI.getQuery(), + containsString("response_type=code")); + } + + @Test + public void getLoginURIWithMfaDisabled() throws Exception { + // Configure with MFA checking disabled + final IdentityProviderConfig idconfig = IdentityProviderConfig.getBuilder( + OrcIDIdentityProviderFactory.class.getName(), + new URL("http://localhost:" + mockClientAndServer.getPort()), + new URL("http://localhost:" + mockClientAndServer.getPort()), + "ofoo", + "obar", + new URL("https://ologinredir.com"), + new URL("https://olinkredir.com")) + .withCustomConfiguration("orcid-mfa-enabled", "false") + .build(); + final IdentityProvider idp = new OrcIDIdentityProvider(idconfig); + + final URI loginURI = idp.getLoginURI("mystate456", "pkce_challenge", false, null); + + assertThat("incorrect URI host", loginURI.getHost(), is("localhost")); + assertThat("incorrect URI path", loginURI.getPath(), is("/oauth/authorize")); + // When MFA is disabled, should use SCOPE_NO_OPENID which doesn't include "openid" + assertThat("incorrect query string", loginURI.getQuery(), + containsString("scope=/authenticate")); + assertThat("incorrect query string", loginURI.getQuery(), + containsString("state=mystate456")); + assertThat("incorrect query string", loginURI.getQuery(), + containsString("response_type=code")); + // Verify that "openid" is NOT in the scope when MFA is disabled + assertThat("scope should not contain openid", loginURI.getQuery(), not(containsString("openid"))); + } + } diff --git a/src/test/java/us/kbase/test/auth2/service/api/APITokenTest.java b/src/test/java/us/kbase/test/auth2/service/api/APITokenTest.java index 9748f0a6..af48566e 100644 --- a/src/test/java/us/kbase/test/auth2/service/api/APITokenTest.java +++ b/src/test/java/us/kbase/test/auth2/service/api/APITokenTest.java @@ -10,6 +10,7 @@ import nl.jqno.equalsverifier.EqualsVerifier; import us.kbase.auth2.lib.UserName; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.token.NewToken; import us.kbase.auth2.lib.token.StoredToken; import us.kbase.auth2.lib.token.TokenType; @@ -20,12 +21,14 @@ public class APITokenTest { @Test public void apiTokenEquals() { - EqualsVerifier.forClass(APIToken.class).usingGetClass().verify(); + EqualsVerifier.forClass(APIToken.class).usingGetClass() + .withNonnullFields("mfa").verify(); } - + @Test public void newApiTokenEquals() { - EqualsVerifier.forClass(NewAPIToken.class).usingGetClass().verify(); + EqualsVerifier.forClass(NewAPIToken.class).usingGetClass() + .withNonnullFields("mfa").verify(); } @Test @@ -39,6 +42,7 @@ TokenType.AGENT, id, new UserName("foo")) assertThat("incorrect id", t.getId(), is(id.toString())); assertThat("incorrect cache time", t.getCachefor(), is(20000L)); + assertThat("incorrect mfa status", t.getMfa(), is(MfaStatus.UNKNOWN)); } diff --git a/src/test/java/us/kbase/test/auth2/service/api/TestModeIntegrationTest.java b/src/test/java/us/kbase/test/auth2/service/api/TestModeIntegrationTest.java index 748a9566..a18c62ae 100644 --- a/src/test/java/us/kbase/test/auth2/service/api/TestModeIntegrationTest.java +++ b/src/test/java/us/kbase/test/auth2/service/api/TestModeIntegrationTest.java @@ -31,6 +31,7 @@ import com.google.common.collect.ImmutableMap; import us.kbase.auth2.kbase.KBaseAuthConfig; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.test.auth2.MapBuilder; import us.kbase.test.auth2.MongoStorageTestManager; import us.kbase.test.auth2.StandaloneAuthServer; @@ -209,6 +210,7 @@ public void createAndGetToken() { expected.put("user", "whee"); expected.put("custom", Collections.emptyMap()); expected.put("cachefor", 300000); + expected.put("mfa", MfaStatus.UNKNOWN.getID()); assertThat("incorrect return", response, is(expected)); diff --git a/src/test/java/us/kbase/test/auth2/service/api/TestModeTest.java b/src/test/java/us/kbase/test/auth2/service/api/TestModeTest.java index a0e1e272..8a8c71ed 100644 --- a/src/test/java/us/kbase/test/auth2/service/api/TestModeTest.java +++ b/src/test/java/us/kbase/test/auth2/service/api/TestModeTest.java @@ -45,6 +45,7 @@ import us.kbase.auth2.lib.exceptions.NoTokenProvidedException; import us.kbase.auth2.lib.exceptions.TestModeException; import us.kbase.auth2.lib.exceptions.UnauthorizedException; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.token.IncomingToken; import us.kbase.auth2.lib.token.NewToken; import us.kbase.auth2.lib.token.StoredToken; @@ -314,7 +315,7 @@ public void createTokenNoName() throws Exception { final UUID uuid = UUID.randomUUID(); - when(auth.testModeCreateToken(new UserName("foo"), null, TokenType.DEV)) + when(auth.testModeCreateToken(new UserName("foo"), null, TokenType.DEV, MfaStatus.UNKNOWN)) .thenReturn(new NewToken(StoredToken.getBuilder( TokenType.DEV, uuid, new UserName("foo")) .withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(20000)) @@ -341,7 +342,7 @@ public void createTokenWithName() throws Exception { final UUID uuid = UUID.randomUUID(); - when(auth.testModeCreateToken(new UserName("foo"), new TokenName("whee"), TokenType.AGENT)) + when(auth.testModeCreateToken(new UserName("foo"), new TokenName("whee"), TokenType.AGENT, MfaStatus.UNKNOWN)) .thenReturn(new NewToken(StoredToken.getBuilder( TokenType.AGENT, uuid, new UserName("foo")) .withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(20000)) @@ -362,7 +363,34 @@ TokenType.AGENT, uuid, new UserName("foo")) assertThat("incorrect token", token, is(expected)); } - + + @Test + public void createTokenWithMfa() throws Exception { + final Authentication auth = mock(Authentication.class); + final TestMode tm = new TestMode(auth); + + final UUID uuid = UUID.randomUUID(); + + when(auth.testModeCreateToken(new UserName("foo"), null, TokenType.DEV, MfaStatus.USED)) + .thenReturn(new NewToken(StoredToken.getBuilder( + TokenType.DEV, uuid, new UserName("foo")) + .withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(20000)) + .build(), + "a token")); + + when(auth.getSuggestedTokenCacheTime()).thenReturn(30000L); + + final NewAPIToken token = tm.createTestToken(new CreateTestToken("foo", null, "Dev", "Used")); + + final NewAPIToken expected = new NewAPIToken(new NewToken(StoredToken.getBuilder( + TokenType.DEV, uuid, new UserName("foo")) + .withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(20000)) + .build(), + "a token"), 30000L); + + assertThat("incorrect token", token, is(expected)); + } + @Test public void createTokenFailNoJson() { final TestMode tm = new TestMode(mock(Authentication.class)); @@ -384,11 +412,19 @@ public void createTokenFailNulls() { @Test public void createTokenFailBadTokenType() { final TestMode tm = new TestMode(mock(Authentication.class)); - + failCreateToken(tm, new CreateTestToken("whee", "foo", "Devv"), new IllegalParameterException("Invalid token type: Devv")); } - + + @Test + public void createTokenFailInvalidMfaStatus() { + final TestMode tm = new TestMode(mock(Authentication.class)); + + failCreateToken(tm, new CreateTestToken("whee", "foo", "Dev", "InvalidMfa"), + new IllegalParameterException("Invalid MFA status: InvalidMfa")); + } + @Test public void createTokenFailAddlProps() { final TestMode tm = new TestMode(mock(Authentication.class)); diff --git a/src/test/java/us/kbase/test/auth2/service/api/TokenEndpointTest.java b/src/test/java/us/kbase/test/auth2/service/api/TokenEndpointTest.java index 4bdb2a34..40743453 100644 --- a/src/test/java/us/kbase/test/auth2/service/api/TokenEndpointTest.java +++ b/src/test/java/us/kbase/test/auth2/service/api/TokenEndpointTest.java @@ -35,6 +35,7 @@ import us.kbase.auth2.lib.PasswordHashAndSalt; import us.kbase.auth2.lib.TokenCreationContext; import us.kbase.auth2.lib.UserName; +import us.kbase.auth2.lib.identity.MfaStatus; import us.kbase.auth2.lib.exceptions.AuthException; import us.kbase.auth2.lib.exceptions.ErrorType; import us.kbase.auth2.lib.exceptions.IllegalParameterException; @@ -174,11 +175,66 @@ TokenType.AGENT, id, new UserName("foo")) .with("user", "foo") .with("custom", ImmutableMap.of("whee", "whoo")) .with("cachefor", 300000) + .with("mfa", MfaStatus.UNKNOWN.getID()) .build(); assertThat("incorrect token", response, is(expected)); } + @Test + public void getTokenMfaIsolation() throws Exception { + // Create user + final UserName userName = new UserName("testuser"); + final UUID userUuid = UUID.randomUUID(); + manager.storage.createLocalUser(LocalUser.getLocalUserBuilder( + userName, userUuid, new DisplayName("Test User"), inst(10000)) + .withEmailAddress(new EmailAddress("test@example.com")).build(), + new PasswordHashAndSalt("passwordhash1234".getBytes(), "salt".getBytes())); + + // Create two tokens with different MFA status + final UUID token1Id = UUID.randomUUID(); + final UUID token2Id = UUID.randomUUID(); + + final IncomingToken token1 = new IncomingToken("token1hash"); + final IncomingToken token2 = new IncomingToken("token2hash"); + + // Token 1 with MFA=true + manager.storage.storeToken(StoredToken.getBuilder( + TokenType.LOGIN, token1Id, userName) + .withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(1000000000000000L)) + .withMfa(MfaStatus.USED) + .build(), token1.getHashedToken().getTokenHash()); + + // Token 2 with MFA=false + manager.storage.storeToken(StoredToken.getBuilder( + TokenType.LOGIN, token2Id, userName) + .withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(1000000000000000L)) + .withMfa(MfaStatus.NOT_USED) + .build(), token2.getHashedToken().getTokenHash()); + + // Test token1 returns MFA=true + final URI target1 = UriBuilder.fromUri(host).path("/api/V2/token").build(); + final WebTarget wt1 = CLI.target(target1); + final Builder req1 = wt1.request().header("authorization", token1.getToken()); + final Response res1 = req1.get(); + + assertThat("incorrect response code for token1", res1.getStatus(), is(200)); + @SuppressWarnings("unchecked") + final Map response1 = res1.readEntity(Map.class); + assertThat("token1 should have MFA=true", response1.get("mfa"), is(MfaStatus.USED.getID())); + + // Test token2 returns MFA=false + final URI target2 = UriBuilder.fromUri(host).path("/api/V2/token").build(); + final WebTarget wt2 = CLI.target(target2); + final Builder req2 = wt2.request().header("authorization", token2.getToken()); + final Response res2 = req2.get(); + + assertThat("incorrect response code for token2", res2.getStatus(), is(200)); + @SuppressWarnings("unchecked") + final Map response2 = res2.readEntity(Map.class); + assertThat("token2 should have MFA=false", response2.get("mfa"), is(MfaStatus.NOT_USED.getID())); + } + @Test public void getTokenFailNoToken() throws Exception { final URI target = UriBuilder.fromUri(host).path("/api/V2/token").build(); @@ -606,4 +662,63 @@ private void kbaseTokenSuccess( assertThat("incorrect response", response, is(expected)); } + + @Test + public void getTokenWithMfaTrue() throws Exception { + testTokenEndpointReturnsMfaAuthenticatedField("mfauser", "MFA User", "mfa@example.com", + "mfatoken", "mfatokenvalue", MfaStatus.USED); + } + + @Test + public void getTokenWithMfaFalse() throws Exception { + testTokenEndpointReturnsMfaAuthenticatedField("nomfauser", "No MFA User", "nomfa@example.com", + "nomfatoken", "nomfatokenvalue", MfaStatus.NOT_USED); + } + + @Test + public void getTokenWithMfaNull() throws Exception { + testTokenEndpointReturnsMfaAuthenticatedField("unknownmfauser", "Unknown MFA User", "unknownmfa@example.com", + "unknownmfatoken", "unknownmfatokenvalue", MfaStatus.UNKNOWN); + } + + private void testTokenEndpointReturnsMfaAuthenticatedField(final String userName, final String displayName, + final String email, final String tokenName, final String tokenValue, + final MfaStatus mfaStatus) throws Exception { + final UUID id = UUID.randomUUID(); + final IncomingToken it = new IncomingToken(tokenValue); + final UserName user = new UserName(userName); + + // Create simple local user + manager.storage.createLocalUser(LocalUser.getLocalUserBuilder( + user, id, new DisplayName(displayName), inst(10000)) + .withEmailAddress(new EmailAddress(email)).build(), + new PasswordHashAndSalt("passwordhash1234".getBytes(), "salt".getBytes())); + + // Create token with specified MFA status + StoredToken.OptionalsStep tokenBuilder = StoredToken.getBuilder( + TokenType.AGENT, id, user) + .withLifeTime(Instant.ofEpochMilli(10000), Instant.ofEpochMilli(1000000000000000L)) + .withTokenName(new TokenName(tokenName)); + + tokenBuilder = tokenBuilder.withMfa(mfaStatus); + + manager.storage.storeToken(tokenBuilder.build(), it.getHashedToken().getTokenHash()); + + final URI target = UriBuilder.fromUri(host).path("/api/V2/token").build(); + + final WebTarget wt = CLI.target(target); + final Builder req = wt.request() + .header("authorization", it.getToken()); + + final Response res = req.get(); + + assertThat("incorrect response code", res.getStatus(), is(200)); + + @SuppressWarnings("unchecked") + final Map response = res.readEntity(Map.class); + + assertThat("incorrect MFA status", response.get("mfa"), is(mfaStatus.getID())); + assertThat("incorrect user", response.get("user"), is(userName)); + assertThat("incorrect token name", response.get("name"), is(tokenName)); + } } diff --git a/src/test/java/us/kbase/test/auth2/service/common/ExternalTokenTest.java b/src/test/java/us/kbase/test/auth2/service/common/ExternalTokenTest.java index 48b5111a..1d44bf51 100644 --- a/src/test/java/us/kbase/test/auth2/service/common/ExternalTokenTest.java +++ b/src/test/java/us/kbase/test/auth2/service/common/ExternalTokenTest.java @@ -24,7 +24,8 @@ public class ExternalTokenTest { @Test public void equals() { - EqualsVerifier.forClass(ExternalToken.class).usingGetClass().verify(); + EqualsVerifier.forClass(ExternalToken.class).usingGetClass() + .withNonnullFields("mfa").verify(); } @Test diff --git a/src/test/java/us/kbase/test/auth2/service/ui/TokensTest.java b/src/test/java/us/kbase/test/auth2/service/ui/TokensTest.java index dbcb507a..57235143 100644 --- a/src/test/java/us/kbase/test/auth2/service/ui/TokensTest.java +++ b/src/test/java/us/kbase/test/auth2/service/ui/TokensTest.java @@ -158,6 +158,7 @@ public void getTokensMinimalInput() throws Exception { .with("name", null) .with("user", "whoo") .with("custom", Collections.emptyMap()) + .with("mfa", "Unknown") .with("os", null) .with("osver", null) .with("agent", null) @@ -260,6 +261,7 @@ public void getTokensMaximalInput() throws Exception { .with("name", "wugga") .with("user", "whoo") .with("custom", ImmutableMap.of("foo", "bar")) + .with("mfa", "Unknown") .with("os", "o") .with("osver", "osv") .with("agent", "ag") @@ -276,6 +278,7 @@ public void getTokensMaximalInput() throws Exception { .with("name", "whee") .with("user", "whoo") .with("custom", Collections.emptyMap()) + .with("mfa", "Unknown") .with("os", null) .with("osver", null) .with("agent", null) @@ -291,6 +294,7 @@ public void getTokensMaximalInput() throws Exception { .with("name", null) .with("user", "whoo") .with("custom", ImmutableMap.of("baz", "bat")) + .with("mfa", "Unknown") .with("os", null) .with("osver", null) .with("agent", "ag2")