| // Copyright (C) 2015 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.httpd.auth.oauth; |
| |
| import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; |
| |
| import com.google.common.base.CharMatcher; |
| import com.google.common.base.Strings; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.common.io.BaseEncoding; |
| import com.google.gerrit.auth.oauth.OAuthTokenCache; |
| import com.google.gerrit.entities.Account; |
| import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider; |
| import com.google.gerrit.extensions.auth.oauth.OAuthToken; |
| import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo; |
| import com.google.gerrit.extensions.auth.oauth.OAuthVerifier; |
| import com.google.gerrit.extensions.registration.DynamicItem; |
| import com.google.gerrit.extensions.restapi.Url; |
| import com.google.gerrit.httpd.CanonicalWebUrl; |
| import com.google.gerrit.httpd.WebSession; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.account.AccountException; |
| import com.google.gerrit.server.account.AccountManager; |
| import com.google.gerrit.server.account.AuthRequest; |
| import com.google.gerrit.server.account.AuthResult; |
| import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.servlet.SessionScoped; |
| import java.io.IOException; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.SecureRandom; |
| import java.util.Optional; |
| import javax.servlet.ServletRequest; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| |
| @SessionScoped |
| /* OAuth protocol implementation */ |
| class OAuthSession { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private static final SecureRandom randomState = newRandomGenerator(); |
| private final String state; |
| private final DynamicItem<WebSession> webSession; |
| private final Provider<IdentifiedUser> identifiedUser; |
| private final AccountManager accountManager; |
| private final CanonicalWebUrl urlProvider; |
| private final OAuthTokenCache tokenCache; |
| private OAuthServiceProvider serviceProvider; |
| private OAuthUserInfo user; |
| private Account.Id accountId; |
| private String redirectToken; |
| private boolean linkMode; |
| private final ExternalIdKeyFactory externalIdKeyFactory; |
| private final AuthRequest.Factory authRequestFactory; |
| |
| @Inject |
| OAuthSession( |
| DynamicItem<WebSession> webSession, |
| Provider<IdentifiedUser> identifiedUser, |
| AccountManager accountManager, |
| CanonicalWebUrl urlProvider, |
| OAuthTokenCache tokenCache, |
| ExternalIdKeyFactory externalIdKeyFactory, |
| AuthRequest.Factory authRequestFactory) { |
| this.state = generateRandomState(); |
| this.identifiedUser = identifiedUser; |
| this.webSession = webSession; |
| this.accountManager = accountManager; |
| this.urlProvider = urlProvider; |
| this.tokenCache = tokenCache; |
| this.externalIdKeyFactory = externalIdKeyFactory; |
| this.authRequestFactory = authRequestFactory; |
| } |
| |
| boolean isLoggedIn() { |
| return user != null; |
| } |
| |
| boolean isOAuthFinal(HttpServletRequest request) { |
| return Strings.emptyToNull(request.getParameter("code")) != null; |
| } |
| |
| boolean login( |
| HttpServletRequest request, HttpServletResponse response, OAuthServiceProvider oauth) |
| throws IOException { |
| logger.atFine().log("Login %s", this); |
| |
| if (isOAuthFinal(request)) { |
| if (!checkState(request)) { |
| response.sendError(HttpServletResponse.SC_NOT_FOUND); |
| return false; |
| } |
| |
| logger.atFine().log("Login-Retrieve-User %s", this); |
| OAuthToken token = oauth.getAccessToken(new OAuthVerifier(request.getParameter("code"))); |
| user = oauth.getUserInfo(token); |
| |
| if (isLoggedIn()) { |
| logger.atFine().log("Login-SUCCESS %s", this); |
| authenticateAndRedirect(request, response, token); |
| return true; |
| } |
| response.sendError(SC_UNAUTHORIZED); |
| return false; |
| } |
| logger.atFine().log("Login-PHASE1 %s", this); |
| redirectToken = request.getRequestURI(); |
| // We are here in content of filter. |
| // Due to this Jetty limitation: |
| // https://bz.apache.org/bugzilla/show_bug.cgi?id=28323 |
| // we cannot use LoginUrlToken.getToken() method, |
| // because it relies on getPathInfo() and it is always null here. |
| redirectToken = redirectToken.substring(request.getContextPath().length()); |
| response.sendRedirect(oauth.getAuthorizationUrl() + "&state=" + state); |
| return false; |
| } |
| |
| private void authenticateAndRedirect( |
| HttpServletRequest req, HttpServletResponse rsp, OAuthToken token) throws IOException { |
| AuthRequest areq = authRequestFactory.create(externalIdKeyFactory.parse(user.getExternalId())); |
| AuthResult arsp; |
| try { |
| String claimedIdentifier = user.getClaimedIdentity(); |
| if (!Strings.isNullOrEmpty(claimedIdentifier)) { |
| if (!authenticateWithIdentityClaimedDuringHandshake(areq, rsp, claimedIdentifier)) { |
| return; |
| } |
| } else if (linkMode) { |
| if (!authenticateWithLinkedIdentity(areq, rsp)) { |
| return; |
| } |
| } |
| areq.setUserName(user.getUserName()); |
| areq.setEmailAddress(user.getEmailAddress()); |
| areq.setDisplayName(user.getDisplayName()); |
| arsp = accountManager.authenticate(areq); |
| |
| accountId = arsp.getAccountId(); |
| tokenCache.put(accountId, token); |
| } catch (AccountException e) { |
| logger.atSevere().withCause(e).log("Unable to authenticate user \"%s\"", user); |
| rsp.sendError(HttpServletResponse.SC_FORBIDDEN); |
| return; |
| } |
| |
| webSession.get().login(arsp, true); |
| String suffix = redirectToken.substring(OAuthWebFilter.GERRIT_LOGIN.length() + 1); |
| suffix = CharMatcher.anyOf("/").trimLeadingFrom(Url.decode(suffix)); |
| StringBuilder rdr = new StringBuilder(urlProvider.get(req)); |
| rdr.append(suffix); |
| rsp.sendRedirect(rdr.toString()); |
| } |
| |
| private boolean authenticateWithIdentityClaimedDuringHandshake( |
| AuthRequest req, HttpServletResponse rsp, String claimedIdentifier) |
| throws AccountException, IOException { |
| Optional<Account.Id> claimedId = accountManager.lookup(claimedIdentifier); |
| Optional<Account.Id> actualId = accountManager.lookup(user.getExternalId()); |
| if (claimedId.isPresent() && actualId.isPresent()) { |
| if (claimedId.get().equals(actualId.get())) { |
| // Both link to the same account, that's what we expected. |
| logger.atFine().log("OAuth2: claimed identity equals current id"); |
| } else { |
| // This is (for now) a fatal error. There are two records |
| // for what might be the same user. |
| // |
| logger.atSevere().log( |
| "OAuth accounts disagree over user identity:\n" |
| + " Claimed ID: %s is %s\n" |
| + " Delgate ID: %s is %s", |
| claimedId.get(), claimedIdentifier, actualId.get(), user.getExternalId()); |
| rsp.sendError(HttpServletResponse.SC_FORBIDDEN); |
| return false; |
| } |
| } else if (claimedId.isPresent() && !actualId.isPresent()) { |
| // Claimed account already exists: link to it. |
| // |
| logger.atInfo().log("OAuth2: linking claimed identity to %s", claimedId.get()); |
| try { |
| accountManager.link(claimedId.get(), req); |
| } catch (ConfigInvalidException e) { |
| logger.atSevere().log( |
| "Cannot link: %s to user identity:\n Claimed ID: %s is %s", |
| user.getExternalId(), claimedId.get(), claimedIdentifier); |
| rsp.sendError(HttpServletResponse.SC_FORBIDDEN); |
| return false; |
| } |
| } |
| return true; |
| } |
| |
| private boolean authenticateWithLinkedIdentity(AuthRequest areq, HttpServletResponse rsp) |
| throws AccountException, IOException { |
| try { |
| accountManager.link(identifiedUser.get().getAccountId(), areq); |
| } catch (ConfigInvalidException e) { |
| logger.atSevere().log( |
| "Cannot link: %s to user identity: %s", |
| user.getExternalId(), identifiedUser.get().getAccountId()); |
| rsp.sendError(HttpServletResponse.SC_FORBIDDEN); |
| return false; |
| } finally { |
| linkMode = false; |
| } |
| return true; |
| } |
| |
| void logout() { |
| if (accountId != null) { |
| tokenCache.remove(accountId); |
| accountId = null; |
| } |
| user = null; |
| redirectToken = null; |
| serviceProvider = null; |
| } |
| |
| private boolean checkState(ServletRequest request) { |
| String s = Strings.nullToEmpty(request.getParameter("state")); |
| if (!s.equals(state)) { |
| logger.atSevere().log("Illegal request state '%s' on OAuthProtocol %s", s, this); |
| return false; |
| } |
| return true; |
| } |
| |
| private static SecureRandom newRandomGenerator() { |
| try { |
| return SecureRandom.getInstance("SHA1PRNG"); |
| } catch (NoSuchAlgorithmException e) { |
| throw new IllegalStateException("No SecureRandom available for GitHub authentication", e); |
| } |
| } |
| |
| private static String generateRandomState() { |
| byte[] state = new byte[32]; |
| randomState.nextBytes(state); |
| return BaseEncoding.base64Url().encode(state); |
| } |
| |
| @Override |
| public String toString() { |
| return "OAuthSession [token=" + tokenCache.get(accountId) + ", user=" + user + "]"; |
| } |
| |
| public void setServiceProvider(OAuthServiceProvider provider) { |
| this.serviceProvider = provider; |
| } |
| |
| public OAuthServiceProvider getServiceProvider() { |
| return serviceProvider; |
| } |
| |
| public void setLinkMode(boolean linkMode) { |
| this.linkMode = linkMode; |
| } |
| |
| public boolean isLinkMode() { |
| return linkMode; |
| } |
| } |