Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/java/uk/ac/cam/cl/dtg/segue/api/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ public String toString() {
public static final String GOOGLE_OAUTH_SCOPES = "GOOGLE_OAUTH_SCOPES";

// Microsoft properties
public static final String MICROSOFT_ALLOW_AUTO_LINKING = "MICROSOFT_ALLOW_AUTO_LINKING";
public static final String MICROSOFT_SECRET = "MICROSOFT_SECRET";
public static final String MICROSOFT_CLIENT_ID = "MICROSOFT_CLIENT_ID";
public static final String MICROSOFT_TENANT_ID = "MICROSOFT_TENANT_ID";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import uk.ac.cam.cl.dtg.segue.auth.IAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.IPasswordAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISecondFactorAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.MicrosoftAutoLinkingConfig;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AdditionalAuthenticationRequiredException;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticationCodeException;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticationProviderMappingException;
Expand Down Expand Up @@ -129,6 +130,7 @@ public class UserAccountManager implements IUserAccountManager {
private final ISecondFactorAuthenticator secondFactorManager;

private final AbstractUserPreferenceManager userPreferenceManager;
private final MicrosoftAutoLinkingConfig microsoftAutoLinkingConfig;

private final Pattern restrictedSignupEmailRegex;
private static final int USER_NAME_MAX_LENGTH = 255;
Expand All @@ -155,7 +157,8 @@ public UserAccountManager(final IUserDataManager database, final QuestionManager
final EmailManager emailQueue, final IAnonymousUserDataManager temporaryUserCache,
final ILogManager logManager, final UserAuthenticationManager userAuthenticationManager,
final ISecondFactorAuthenticator secondFactorManager,
final AbstractUserPreferenceManager userPreferenceManager) {
final AbstractUserPreferenceManager userPreferenceManager,
final MicrosoftAutoLinkingConfig msAutoLinkingConfig) {

Objects.requireNonNull(properties.getProperty(HMAC_SALT));
Objects.requireNonNull(properties.getProperty(SESSION_EXPIRY_SECONDS_DEFAULT));
Expand All @@ -177,6 +180,7 @@ public UserAccountManager(final IUserDataManager database, final QuestionManager
this.userAuthenticationManager = userAuthenticationManager;
this.secondFactorManager = secondFactorManager;
this.userPreferenceManager = userPreferenceManager;
this.microsoftAutoLinkingConfig = msAutoLinkingConfig;

String forbiddenEmailRegex = properties.getProperty(RESTRICTED_SIGNUP_EMAIL_REGEX);
if (null == forbiddenEmailRegex || forbiddenEmailRegex.isEmpty()) {
Expand Down Expand Up @@ -267,6 +271,17 @@ public RegisteredUserDTO authenticateCallback(final HttpServletRequest request,
}

RegisteredUser currentUser = getCurrentRegisteredUserDO(request);
RegisteredUser matchedUserFromEmail = this.findUserByEmail(providerUserDO.getEmail());

if (microsoftAutoLinkingConfig.enabledFor(providerUserDO.getEmail()) &&
userFromLinkedAccount == null &&
currentUser == null &&
providerUserDO.getEmail() != null &&
!providerUserDO.getEmail().isEmpty() &&
matchedUserFromEmail != null) {
return this.logUserIn(request, response, matchedUserFromEmail, rememberMe);
}

// if the user is currently logged in and this is a request for a linked account, then create the new link.
if (null != currentUser) {
Boolean intentionToLinkRegistered = (Boolean) request.getSession().getAttribute(LINK_ACCOUNT_PARAM_NAME);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package uk.ac.cam.cl.dtg.segue.auth;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.inject.Inject;
import com.google.inject.name.Named;
import org.apache.commons.lang3.StringUtils;
import uk.ac.cam.cl.dtg.segue.api.Constants;
import uk.ac.cam.cl.dtg.segue.api.managers.UserAccountManager;

import jakarta.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Predicate;

public class MicrosoftAutoLinkingConfig {
private List<ConfigEntry> entries;

@Inject
public MicrosoftAutoLinkingConfig(
@Nullable @Named(Constants.MICROSOFT_ALLOW_AUTO_LINKING) final String jsonConfig
) {
try {
this.entries = new ObjectMapper().readValue(jsonConfig, new TypeReference<>() {});
} catch (final JsonProcessingException | IllegalArgumentException e) {
this.entries = new ArrayList<>();
}
}

public boolean enabledFor(final String email) {
return UserAccountManager.isUserEmailValid(email)
&& this.entries.stream().anyMatch(ConfigEntry.matchingDomain(email));
}

public int size() {
return this.entries.size();
}

private static class ConfigEntry {
String tenantId;
String emailDomain;

@JsonCreator
public ConfigEntry(
@JsonProperty("tenantId") final String tenantId,
@JsonProperty("emailDomain") final String emailDomain
) {
this.tenantId = tenantId;
this.emailDomain = emailDomain;
}

public static Predicate<ConfigEntry> matchingDomain(final String email) {
return entry -> entry.emailDomain.equals(StringUtils.substringAfter(email, '@'));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
import uk.ac.cam.cl.dtg.segue.auth.IAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISecondFactorAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISegueHashingAlgorithm;
import uk.ac.cam.cl.dtg.segue.auth.MicrosoftAutoLinkingConfig;
import uk.ac.cam.cl.dtg.segue.auth.RaspberryPiOidcAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.SegueChainedPBKDFv1SCryptv1;
import uk.ac.cam.cl.dtg.segue.auth.SegueChainedPBKDFv2SCryptv1;
Expand Down Expand Up @@ -367,6 +368,7 @@ private void configureAuthenticationProviders() {

// Microsoft
try {
this.bindConstantToNullableProperty(MICROSOFT_ALLOW_AUTO_LINKING, globalProperties);
new MicrosoftAuthenticator(
globalProperties.getProperty(Constants.MICROSOFT_CLIENT_ID),
globalProperties.getProperty(Constants.MICROSOFT_TENANT_ID),
Expand Down Expand Up @@ -807,11 +809,12 @@ private IUserAccountManager getUserManager(final IUserDataManager database, fina
final ILogManager logManager, final MapperFacade mapperFacade,
final UserAuthenticationManager userAuthenticationManager,
final ISecondFactorAuthenticator secondFactorManager,
final AbstractUserPreferenceManager userPreferenceManager) {
final AbstractUserPreferenceManager userPreferenceManager,
final MicrosoftAutoLinkingConfig microsoftAutoLinkingConfig) {
if (null == userManager) {
userManager = new UserAccountManager(database, questionManager, properties, providersToRegister,
mapperFacade, emailQueue, temporaryUserCache, logManager, userAuthenticationManager,
secondFactorManager, userPreferenceManager);
secondFactorManager, userPreferenceManager, microsoftAutoLinkingConfig);
log.info("Creating singleton of UserManager");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
import uk.ac.cam.cl.dtg.segue.auth.IAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISecondFactorAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISegueHashingAlgorithm;
import uk.ac.cam.cl.dtg.segue.auth.MicrosoftAutoLinkingConfig;
import uk.ac.cam.cl.dtg.segue.auth.RaspberryPiOidcAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.SegueLocalAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.SeguePBKDF2v3;
Expand Down Expand Up @@ -151,6 +152,7 @@ public class AbstractIsaacIntegrationTest {
protected static IQuizQuestionAttemptPersistenceManager quizQuestionAttemptPersistenceManager;
protected static QuizQuestionManager quizQuestionManager;
protected static PgUsers pgUsers;
protected static MicrosoftAutoLinkingConfig microsoftAutoLinkingConfig;

// Services
protected static AssignmentService assignmentService;
Expand Down Expand Up @@ -282,7 +284,8 @@ public static void setUpClass() throws Exception {
}
replay(secondFactorManager);

userAccountManager = new UserAccountManager(pgUsers, questionManager, properties, providersToRegister, mapperFacade, emailManager, pgAnonymousUsers, logManager, userAuthenticationManager, secondFactorManager, userPreferenceManager);
microsoftAutoLinkingConfig = new MicrosoftAutoLinkingConfig(properties.getProperty("MICROSOFT_ALLOW_AUTO_LINKING"));
userAccountManager = new UserAccountManager(pgUsers, questionManager, properties, providersToRegister, mapperFacade, emailManager, pgAnonymousUsers, logManager, userAuthenticationManager, secondFactorManager, userPreferenceManager, microsoftAutoLinkingConfig);

ObjectMapper objectMapper = new ObjectMapper();
mailGunEmailManager = new MailGunEmailManager(globalTokens, properties, userPreferenceManager);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.junit.jupiter.api.Test;
import uk.ac.cam.cl.dtg.isaac.dto.users.RegisteredUserDTO;
import uk.ac.cam.cl.dtg.segue.api.AuthenticationFacade;
import uk.ac.cam.cl.dtg.segue.api.UsersFacade;
import uk.ac.cam.cl.dtg.segue.auth.AuthenticationProvider;
import uk.ac.cam.cl.dtg.segue.auth.MicrosoftAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.microsoft.KeyPair;
Expand Down Expand Up @@ -86,7 +87,21 @@ public void matchedAccountNotConnected_returnsNotUsingMicrosoftResponse() throws

response.assertError(notUsingMicrosoftMessage, Response.Status.FORBIDDEN);
response.assertNoUserLoggedIn();
}

@Test
public void matchedAccountNotConnectedAutoLinkingEnabled_signsInAndReturnsUser() throws Exception {
var client = prepareTestCase(token.valid(s -> s, u -> {
u.put("oid", UUID.randomUUID().toString());
u.put("email", "not_linked@linkable.com");
return null;
}));
var userId = client.register("not_linked@linkable.com");

var response = client.get("/auth/microsoft/callback" + validQuery);

response.assertEntityReturned(userAccountManager.getUserDTOById(userId));
response.assertUserLoggedIn(userId);
}

@Test
Expand Down Expand Up @@ -171,7 +186,11 @@ public void initialSignup_addsForceSignUpParameterToRedirectURL() throws Excepti
}

TestServer subject() throws Exception {
return startServer(new AuthenticationFacade(properties, userAccountManager, logManager, misuseMonitor));
return startServer(
new AuthenticationFacade(properties, userAccountManager, logManager, misuseMonitor),
new UsersFacade(properties, userAccountManager, logManager, userAssociationManager,
misuseMonitor, userPreferenceManager, schoolListReader)
);
}

TestClient prepareTestCase(final String token) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import jakarta.ws.rs.core.Application;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -123,7 +124,7 @@ static class TestClient {
this.baseUrl = baseUrl;
this.registerCleanup = registerCleanup;
this.builder = builder;
this.client = ClientBuilder.newClient().register(new CookieJarFilter());
this.client = ClientBuilder.newClient();
}

public TestResponse get(final String url) {
Expand All @@ -141,12 +142,30 @@ public TestResponse post(final String url, final Object body) {
}

public void loginAs(final RegisteredUser user) {
this.client.register(new CookieJarFilter());
var request = client.target(baseUrl + "/auth/SEGUE/authenticate").request(MediaType.APPLICATION_JSON);
var body = new LocalAuthDTO();
body.setEmail(user.getEmail());
body.setPassword("test1234");
this.currentUser = builder.apply(request).post(Entity.json(body), RegisteredUserDTO.class);
}

public long register(final String email) {
var user = new HashMap<>();
user.put("email", email);
user.put("password", ITConstants.TEST_SIGNUP_PASSWORD);
user.put("familyName", "signup");
user.put("givenName", "test");

var payload = new HashMap<>();
payload.put("registeredUser", user);
payload.put("userPreferences", new HashMap<>());
payload.put("passwordCurrent", null);

TestResponse response = this.post("/users", payload);
response.assertStatus(Response.Status.OK.getStatusCode());
return response.readEntity(RegisteredUserDTO.class).getId();
}
}

static class TestResponse {
Expand Down Expand Up @@ -178,9 +197,13 @@ <T> void assertEntityReturned(final T entity) {
}

<T> T readEntity(final Class<T> klass) {
assertEquals(Response.Status.OK.getStatusCode(), response.getStatus());
this.assertStatus(Response.Status.OK.getStatusCode());
return response.readEntity(klass);
}

void assertStatus(int expectedStatus) {
assertEquals(expectedStatus, response.getStatus());
}
}

interface RequestBuilder extends Function<Invocation.Builder, Invocation.Builder> {}
Expand Down
2 changes: 1 addition & 1 deletion src/test/java/uk/ac/cam/cl/dtg/isaac/api/SignupFlowIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public void signUpFlow_emailVerificationRequiredCaveatSet_removesCaveatAfterVeri
// set up email facade
UserAccountManager userAccountManagerForTest = new UserAccountManager(pgUsers, questionManager,
propertiesForTest, providersToRegister, mapperFacade, emailManager, pgAnonymousUsers, logManager,
userAuthenticationManager, secondFactorManager, userPreferenceManager);
userAuthenticationManager, secondFactorManager, userPreferenceManager, microsoftAutoLinkingConfig);

EmailFacade emailFacade = new EmailFacade(propertiesForTest, logManager, emailManager,
userAccountManagerForTest, contentManager, misuseMonitor);
Expand Down
4 changes: 2 additions & 2 deletions src/test/java/uk/ac/cam/cl/dtg/isaac/api/UsersFacadeIT.java
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public void createOrUpdateEndpoint_registerAsTeacherWithSignUpFlagEnabled_accept

UserAccountManager userAccountManagerForTest = new UserAccountManager(pgUsers, questionManager,
propertiesForTest, providersToRegister, mapperFacade, emailManager, pgAnonymousUsers, logManager,
userAuthenticationManager, secondFactorManager, userPreferenceManager);
userAuthenticationManager, secondFactorManager, userPreferenceManager, microsoftAutoLinkingConfig);

UsersFacade usersFacadeForTest = new UsersFacade(propertiesForTest, userAccountManagerForTest, logManager,
userAssociationManager, misuseMonitor, userPreferenceManager, schoolListReader);
Expand Down Expand Up @@ -833,7 +833,7 @@ public void generatePasswordResetTokenForOtherUser_teacherResetsStudentPassword_
pgUsers, dummyDeletionTokenManager, properties, providersToRegister, dummyEmailManager);
UserAccountManager userAccountManager = new UserAccountManager(
pgUsers, questionManager, properties, providersToRegister, mapperFacade, emailManager, pgAnonymousUsers,
logManager, userAuthenticationManager, secondFactorManager, userPreferenceManager);
logManager, userAuthenticationManager, secondFactorManager, userPreferenceManager, microsoftAutoLinkingConfig);
UsersFacade usersFacadeForTest = new UsersFacade(properties, userAccountManager, logManager,
userAssociationManager, misuseMonitor, userPreferenceManager, schoolListReader);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import uk.ac.cam.cl.dtg.segue.auth.IFederatedAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.IOAuth2Authenticator;
import uk.ac.cam.cl.dtg.segue.auth.ISecondFactorAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.MicrosoftAutoLinkingConfig;
import uk.ac.cam.cl.dtg.segue.auth.SegueLocalAuthenticator;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.AuthenticationProviderMappingException;
import uk.ac.cam.cl.dtg.segue.auth.exceptions.CrossSiteRequestForgeryException;
Expand Down Expand Up @@ -788,7 +789,7 @@ private UserAccountManager buildTestUserManager(final AuthenticationProvider pro
return new UserAccountManager(dummyDatabase, this.dummyQuestionDatabase, this.dummyPropertiesLoader,
providerMap, this.dummyMapper, this.dummyQueue, this.dummyUserCache, this.dummyLogManager,
buildTestAuthenticationManager(provider, authenticator), dummySecondFactorAuthenticator,
dummyUserPreferenceManager);
dummyUserPreferenceManager, new MicrosoftAutoLinkingConfig("{}"));
}

private UserAuthenticationManager buildTestAuthenticationManager() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package uk.ac.cam.cl.dtg.segue.auth;

import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;

import static org.junit.Assert.*;

@SuppressWarnings("checkstyle:MissingJavadocType")
public class MicrosoftAutoLinkingConfigTest {
private final String emptyConfig = "{}";
private final String tenantId = "766576da-ca7a-46eb-bf55-43eea65e85b7";
private final String emailDomain = "enabled.com";
private final String validConfig = new JSONArray()
.put(new JSONObject().put("tenantId", tenantId).put("emailDomain", emailDomain))
.toString();

@Test
public final void testConstruction_emptyConfig_noConfigParsed() {
assertEquals(0, new MicrosoftAutoLinkingConfig(emptyConfig).size());
}

@Test
public final void testConstruction_nullConfig_noConfigParsed() {
assertEquals(0, new MicrosoftAutoLinkingConfig(null).size());
}

@Test
public final void testConstruction_invalidConfig_noConfigParsed() {
assertEquals(0, new MicrosoftAutoLinkingConfig("invalid json").size());
}

@Test
public final void testConstruction_validConfig_parsedSuccessfully() {
assertEquals(1, new MicrosoftAutoLinkingConfig(validConfig).size());
}

@Test
public final void testEnabledFor_matchingDomain_returnsTrue() {
var autoLinkingConfig = new MicrosoftAutoLinkingConfig(validConfig);
assertTrue(autoLinkingConfig.enabledFor("someone@enabled.com"));
}

@Test
public final void testEnabledFor_mistmatchedDomain_returnsFalse() {
var autoLinkingConfig = new MicrosoftAutoLinkingConfig(validConfig);
assertFalse(autoLinkingConfig.enabledFor("someone@disabled.com"));
}

@Test
public final void testEnabledFor_invalidEmail_returnsFalse() {
var autoLinkingConfig = new MicrosoftAutoLinkingConfig(validConfig);
assertFalse(autoLinkingConfig.enabledFor("@enabled.com"));
}
}
2 changes: 2 additions & 0 deletions src/test/resources/segue-integration-test-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ SESSION_EXPIRY_SECONDS_DEFAULT: "432000"
SESSION_EXPIRY_SECONDS_REMEMBERED: "1209600"
EMAIL_VERIFICATION_ENDPOINT_TOKEN: some-very-secret-token
# Federated Authentication
# Microsoft
MICROSOFT_ALLOW_AUTO_LINKING: "[{\"tenantId\": \"766576da-ca7a-46eb-bf55-43eea65e85b7\", \"emailDomain\": \"linkable.com\"}]"
# Google
GOOGLE_CLIENT_SECRET_LOCATION: x
GOOGLE_CALLBACK_URI: x
Expand Down
Loading