Skip to content
Open
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 build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dependencies {
staticAnalysis("org.jacoco:org.jacoco.build:${jacocoVersion}")

implementation("com.google.api-client:google-api-client:2.9.0")
implementation("com.microsoft.azure:msal4j:1.25.0")
implementation(platform("com.google.cloud:google-cloud-bom:0.256.0"))
implementation("com.google.cloud:google-cloud-tasks")
implementation("com.google.cloud:google-cloud-logging")
Expand Down
1 change: 1 addition & 0 deletions src/main/java/teammates/common/datatransfer/Provider.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@
public enum Provider {
TEAMMATES_DEV,
GOOGLE,
MICROSOFT,
}
12 changes: 12 additions & 0 deletions src/main/java/teammates/common/util/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,15 @@ public final class Config {
/** Value of {@code app.oidc.google.client.secret}. */
public static final String OIDC_GOOGLE_CLIENT_SECRET;

/** Value of {@code app.oidc.microsoft.client.id}. */
public static final String OIDC_MICROSOFT_CLIENT_ID;

/** Value of {@code app.oidc.microsoft.client.secret}. */
public static final String OIDC_MICROSOFT_CLIENT_SECRET;

/** Value of {@code app.oidc.microsoft.tenant.id}. */
public static final String OIDC_MICROSOFT_TENANT_ID;

/** Value of {@code app.captcha.secretkey}. */
public static final String CAPTCHA_SECRET_KEY;

Expand Down Expand Up @@ -209,6 +218,9 @@ public final class Config {
LOGIN_METHODS = getLoginMethods(getProperty(properties, devProperties, "app.login.methods").split(","));
OIDC_GOOGLE_CLIENT_ID = getProperty(properties, devProperties, "app.oidc.google.client.id");
OIDC_GOOGLE_CLIENT_SECRET = getProperty(properties, devProperties, "app.oidc.google.client.secret");
OIDC_MICROSOFT_CLIENT_ID = getProperty(properties, devProperties, "app.oidc.microsoft.client.id");
OIDC_MICROSOFT_CLIENT_SECRET = getProperty(properties, devProperties, "app.oidc.microsoft.client.secret");
OIDC_MICROSOFT_TENANT_ID = getProperty(properties, devProperties, "app.oidc.microsoft.tenant.id", "common");
CAPTCHA_SECRET_KEY = getProperty(properties, devProperties, "app.captcha.secretkey");
RECAPTCHA_SERVICE = getProperty(properties, devProperties, "app.recaptcha.service",
IS_DEV_SERVER ? "local" : "google");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package teammates.ui.loginmethodhandlers;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutionException;

import jakarta.servlet.http.HttpServletRequest;

import org.apache.http.HttpStatus;

import com.microsoft.aad.msal4j.AuthorizationCodeParameters;
import com.microsoft.aad.msal4j.AuthorizationRequestUrlParameters;
import com.microsoft.aad.msal4j.ClientCredentialFactory;
import com.microsoft.aad.msal4j.ConfidentialClientApplication;
import com.microsoft.aad.msal4j.IAuthenticationResult;

import teammates.common.datatransfer.Provider;
import teammates.common.util.Config;
import teammates.common.util.JsonUtils;
import teammates.common.util.Logger;
import teammates.common.util.StringHelper;
import teammates.ui.exception.AuthException;
import teammates.ui.output.LoginMethod;

import tools.jackson.core.type.TypeReference;

/**
* Login method handler for Microsoft Entra login.
*/
public class MicrosoftLoginHandler implements LoginMethodHandler {

private static final Logger log = Logger.getLogger();

private static final Set<String> SCOPES = Set.of("openid", "email");
private static final Set<String> MULTI_TENANT_AUTHORITIES = Set.of("common", "organizations");

private final String expectedTenantId;

public MicrosoftLoginHandler() {
this(Config.OIDC_MICROSOFT_TENANT_ID);
}

// For dependency injection.
MicrosoftLoginHandler(String expectedTenantId) {
this.expectedTenantId = expectedTenantId;
}

@Override
public String handleLogin(HttpServletRequest req, String nextUrl) throws IOException, AuthException {
AuthState state = new AuthState(nextUrl, req.getSession().getId(), LoginMethod.MICROSOFT);
String encryptedState = StringHelper.encrypt(JsonUtils.toCompactJson(state));

String loginUrl = getAuthorizationRequestUrl(getRedirectUri(req), encryptedState);

log.request(req, HttpStatus.SC_MOVED_TEMPORARILY, "Redirect to Microsoft Entra sign-in page");

return loginUrl;
}

String getAuthorizationRequestUrl(String redirectUri, String encryptedState) throws AuthException {
AuthorizationRequestUrlParameters parameters = AuthorizationRequestUrlParameters
.builder(redirectUri, SCOPES)
.state(encryptedState)
.build();

return getClientApplication().getAuthorizationRequestUrl(parameters).toString();
}

@Override
public AuthResult handleCallback(HttpServletRequest req, AuthState state) throws IOException, AuthException {
String error = req.getParameter("error");
if (error != null) {
throw new AuthException("Error in Microsoft Entra OAuth2 callback: " + error);
}

String code = req.getParameter("code");
if (code == null) {
throw new AuthException("Missing authorization code");
}

String sessionId = state.sessionId();
if (!sessionId.equals(req.getSession().getId())) {
String message = String.format("Different session ID: expected %s, got %s",
sessionId, req.getSession().getId());
throw new AuthException(message);
}

IAuthenticationResult token = requestToken(code, getRedirectUri(req));
Map<String, Object> claims = parseIdTokenClaims(token.idToken());

String tenantId = getRequiredClaim(claims, "tid");
if (!isExpectedTenantId(tenantId)) {
throw new AuthException("Invalid tenant ID: expected " + expectedTenantId
+ ", got " + tenantId);
}

String email = getRequiredClaim(claims, "email");
if (StringHelper.isEmpty(email)) {
// Require a valid email.
// Microsoft Entra ID tokens may not always contain the email claim.
throw new AuthException("Missing email claim in Microsoft Entra ID token");
}

String subject = getRequiredClaim(claims, "sub");

return new AuthResult(Provider.MICROSOFT, subject, tenantId, email);
}

IAuthenticationResult requestToken(String code, String redirectUri) throws AuthException {
try {
AuthorizationCodeParameters parameters = AuthorizationCodeParameters
.builder(code, new URI(redirectUri))
.scopes(SCOPES)
.build();
return getClientApplication().acquireToken(parameters).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new AuthException("Interrupted while requesting Microsoft Entra token", e);
} catch (ExecutionException | URISyntaxException e) {
throw new AuthException("Failed to request Microsoft Entra token", e);
}
}

private Map<String, Object> parseIdTokenClaims(String rawIdToken) throws AuthException {
if (StringHelper.isEmpty(rawIdToken)) {
throw new AuthException("Missing ID token");
}

String[] tokenParts = rawIdToken.split("\\.");
if (tokenParts.length < 2) {
throw new AuthException("Invalid ID token");
}
try {
String claimsJson = new String(Base64.getUrlDecoder().decode(tokenParts[1]), StandardCharsets.UTF_8);
return JsonUtils.fromJson(claimsJson, new TypeReference<>() {});
} catch (IllegalArgumentException e) {
throw new AuthException("Invalid ID token claims", e);
}
}

private String getRequiredClaim(Map<String, Object> claims, String claimName) throws AuthException {
Object rawValue = claims.get(claimName);
String value = rawValue instanceof String ? (String) rawValue : null;
if (StringHelper.isEmpty(value)) {
throw new AuthException("Missing " + claimName + " claim");
}
return value;
}

private boolean isExpectedTenantId(String tenantId) {
if (StringHelper.isEmpty(tenantId)) {
return false;
}
// Either expecting multi-tenant or the specific tenant ID.
return MULTI_TENANT_AUTHORITIES.contains(expectedTenantId)
|| expectedTenantId.equals(tenantId);
}

private ConfidentialClientApplication getClientApplication() throws AuthException {
try {
return ConfidentialClientApplication
.builder(Config.OIDC_MICROSOFT_CLIENT_ID,
ClientCredentialFactory.createFromSecret(Config.OIDC_MICROSOFT_CLIENT_SECRET))
.authority("https://login.microsoftonline.com/" + expectedTenantId)
.build();
} catch (Exception e) {
throw new AuthException("Failed to create Microsoft Entra client application", e);
}
}

/**
* Returns the redirect URI for the given HTTP servlet request.
*/
private String getRedirectUri(HttpServletRequest req) throws AuthException {
String requestUrl = req.getRequestURL().toString();
if (Config.isDevServerLoginEnabled()) {
requestUrl = requestUrl.replaceFirst("^https://", "http://");
} else {
requestUrl = requestUrl.replaceFirst("^http://", "https://");
}
try {
URI uri = new URI(requestUrl);
return new URI(uri.getScheme(), uri.getAuthority(), "/oauth2callback", null, null).toString();
} catch (URISyntaxException e) {
throw new AuthException("Invalid Microsoft Entra redirect URI", e);
}
}

}
1 change: 1 addition & 0 deletions src/main/java/teammates/ui/output/LoginMethod.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
public enum LoginMethod {
GOOGLE("google"),
MICROSOFT("microsoft"),
DEV_SERVER("devserver");

private final String method;
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/teammates/ui/servlets/AuthServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import teammates.ui.loginmethodhandlers.DevServerLoginHandler;
import teammates.ui.loginmethodhandlers.GoogleLoginHandler;
import teammates.ui.loginmethodhandlers.LoginMethodHandler;
import teammates.ui.loginmethodhandlers.MicrosoftLoginHandler;
import teammates.ui.output.LoginMethod;

/**
Expand All @@ -22,7 +23,8 @@ abstract class AuthServlet extends HttpServlet {

private static final Map<LoginMethod, LoginMethodHandler> LOGIN_HANDLERS = Map.of(
LoginMethod.DEV_SERVER, new DevServerLoginHandler(),
LoginMethod.GOOGLE, new GoogleLoginHandler());
LoginMethod.GOOGLE, new GoogleLoginHandler(),
LoginMethod.MICROSOFT, new MicrosoftLoginHandler());

Cookie getLoginInvalidationCookie() {
Cookie cookie = new Cookie(Const.SecurityConfig.AUTH_COOKIE_NAME, "");
Expand Down
1 change: 1 addition & 0 deletions src/main/java/teammates/ui/servlets/LoginServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOExc
redirectUrl = resp.encodeRedirectURL(redirectUrl);
resp.sendRedirect(redirectUrl);
} catch (Exception e) {
log.severe("Failed to handle login request", e);
resp.sendError(HttpStatus.SC_INTERNAL_SERVER_ERROR);
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/teammates/ui/servlets/OAuth2CallbackServlet.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,23 @@ public OAuth2CallbackServlet() {

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
handleCallback(req, resp);
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
handleCallback(req, resp);
}

/**
* Handles the main callback logic.
*/
private void handleCallback(HttpServletRequest req, HttpServletResponse resp) throws IOException {
AuthState state;
try {
state = getValidAuthStateFromCallback(req);
} catch (AuthException e) {
log.warning("Invalid state parameter in OAuth2 callback", e);
rejectLogin(req, resp, HttpStatus.SC_BAD_REQUEST);
return;
}
Expand All @@ -63,9 +76,11 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IO
AuthResult authResult = loginHandler.handleCallback(req, state);
cookie = getLoginCookie(authResult);
} catch (AuthException e) {
log.warning("Failed to handle OAuth2 callback", e);
rejectLogin(req, resp, HttpStatus.SC_BAD_REQUEST);
return;
} catch (Exception e) {
log.severe("Unexpected error during OAuth2 callback", e);
rejectLogin(req, resp, HttpStatus.SC_INTERNAL_SERVER_ERROR);
return;
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/build-dev.template.properties
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ app.postgres.username=teammates
app.postgres.password=teammates

# This is the list of login methods allowed for users to log in to the system.
# Acceptable values are google, devserver (Only for development environment)
# Acceptable values are google, microsoft, devserver (Only for development environment)
# Separate with commas for multiple values with no leading/trailing spaces.
app.login.methods=devserver

Expand Down
9 changes: 8 additions & 1 deletion src/main/resources/build.template.properties
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ app.encryption.key=5360b12f6a07af7be93437d215f72fca1234567890ABCDEF1234567890ABC
app.hmac.key=47e3fb2793b364f008c389d6b59e8a29108c911f2a9d224173b3d14108458940

# This is the list of login methods allowed for users to log in to the system.
# Acceptable values are google, devserver (Only for development environment)
# Acceptable values are google, microsoft, devserver (Only for development environment)
# Separate with commas for multiple values with no leading/trailing spaces.
app.login.methods=

Expand All @@ -82,6 +82,13 @@ app.login.methods=
app.oidc.google.client.id=
app.oidc.google.client.secret=

# This is only relevant if Microsoft Entra is used as an OIDC provider.
# Use "common" for multi-tenant apps, "organizations" for work/school accounts,
# or a specific tenant ID for single-tenant apps.
app.oidc.microsoft.client.id=
app.oidc.microsoft.client.secret=
app.oidc.microsoft.tenant.id=common

# This is the secret key for communication between the site and reCAPTCHA.
# You can get a pair of keys from the Google reCAPTCHA website.
# e.g. "6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe" is a secret key for test environments.
Expand Down
Loading
Loading