// Copyright (C) 2009 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.openid;

import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.common.auth.openid.OpenIdUrls;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.KeyUtil;
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.ProxyProperties;
import com.google.gerrit.httpd.WebSession;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.UrlEncoded;
import com.google.gerrit.server.account.AccountException;
import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
import com.google.gerrit.server.config.AuthConfig;
import com.google.gerrit.server.config.ConfigUtil;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.net.URL;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jgit.lib.Config;
import org.openid4java.consumer.ConsumerException;
import org.openid4java.consumer.ConsumerManager;
import org.openid4java.consumer.VerificationResult;
import org.openid4java.discovery.DiscoveryException;
import org.openid4java.discovery.DiscoveryInformation;
import org.openid4java.message.AuthRequest;
import org.openid4java.message.Message;
import org.openid4java.message.MessageException;
import org.openid4java.message.MessageExtension;
import org.openid4java.message.ParameterList;
import org.openid4java.message.ax.AxMessage;
import org.openid4java.message.ax.FetchRequest;
import org.openid4java.message.ax.FetchResponse;
import org.openid4java.message.pape.PapeMessage;
import org.openid4java.message.pape.PapeRequest;
import org.openid4java.message.pape.PapeResponse;
import org.openid4java.message.sreg.SRegMessage;
import org.openid4java.message.sreg.SRegRequest;
import org.openid4java.message.sreg.SRegResponse;
import org.openid4java.util.HttpClientFactory;

@Singleton
class OpenIdServiceImpl {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();

  static final String RETURN_URL = "OpenID";

  private static final String P_MODE = "gerrit.mode";
  private static final String P_TOKEN = "gerrit.token";
  private static final String P_REMEMBER = "gerrit.remember";
  private static final String P_CLAIMED = "gerrit.claimed";
  private static final int LASTID_AGE = 365 * 24 * 60 * 60; // seconds

  private static final String OPENID_MODE = "openid.mode";
  private static final String OMODE_CANCEL = "cancel";

  private static final String SCHEMA_EMAIL = "http://schema.openid.net/contact/email";
  private static final String SCHEMA_FIRSTNAME = "http://schema.openid.net/namePerson/first";
  private static final String SCHEMA_LASTNAME = "http://schema.openid.net/namePerson/last";

  private final DynamicItem<WebSession> webSession;
  private final Provider<IdentifiedUser> identifiedUser;
  private final CanonicalWebUrl urlProvider;
  private final AccountManager accountManager;
  private final ConsumerManager manager;
  private final List<OpenIdProviderPattern> allowedOpenIDs;
  private final List<String> openIdDomains;

  /** Maximum age, in seconds, before forcing re-authentication of account. */
  private final int papeMaxAuthAge;

  @Inject
  OpenIdServiceImpl(
      DynamicItem<WebSession> cf,
      Provider<IdentifiedUser> iu,
      CanonicalWebUrl up,
      @GerritServerConfig Config config,
      AuthConfig ac,
      AccountManager am,
      ProxyProperties proxyProperties) {

    if (proxyProperties.getProxyUrl() != null) {
      final org.openid4java.util.ProxyProperties proxy = new org.openid4java.util.ProxyProperties();
      URL url = proxyProperties.getProxyUrl();
      proxy.setProxyHostName(url.getHost());
      proxy.setProxyPort(url.getPort());
      proxy.setUserName(proxyProperties.getUsername());
      proxy.setPassword(proxyProperties.getPassword());
      HttpClientFactory.setProxyProperties(proxy);
    }

    webSession = cf;
    identifiedUser = iu;
    urlProvider = up;
    accountManager = am;
    manager = new ConsumerManager();
    allowedOpenIDs = ac.getAllowedOpenIDs();
    openIdDomains = ac.getOpenIdDomains();
    papeMaxAuthAge =
        (int)
            ConfigUtil.getTimeUnit(
                config, //
                "auth",
                null,
                "maxOpenIdSessionAge",
                -1,
                TimeUnit.SECONDS);
  }

  @SuppressWarnings("unchecked")
  DiscoveryResult discover(
      HttpServletRequest req,
      String openidIdentifier,
      SignInMode mode,
      boolean remember,
      String returnToken) {
    final State state;
    state = init(req, openidIdentifier, mode, remember, returnToken);
    if (state == null) {
      return new DiscoveryResult(DiscoveryResult.Status.NO_PROVIDER);
    }

    final AuthRequest aReq;
    try {
      aReq = manager.authenticate(state.discovered, state.retTo.toString());
      logger.atFine().log("OpenID: openid-realm=%s", state.contextUrl);
      aReq.setRealm(state.contextUrl);

      if (requestRegistration(aReq)) {
        final SRegRequest sregReq = SRegRequest.createFetchRequest();
        sregReq.addAttribute("fullname", true);
        sregReq.addAttribute("email", true);
        aReq.addExtension(sregReq);

        final FetchRequest fetch = FetchRequest.createFetchRequest();
        fetch.addAttribute("FirstName", SCHEMA_FIRSTNAME, true);
        fetch.addAttribute("LastName", SCHEMA_LASTNAME, true);
        fetch.addAttribute("Email", SCHEMA_EMAIL, true);
        aReq.addExtension(fetch);
      }

      if (0 <= papeMaxAuthAge) {
        final PapeRequest pape = PapeRequest.createPapeRequest();
        pape.setMaxAuthAge(papeMaxAuthAge);
        aReq.addExtension(pape);
      }
    } catch (MessageException | ConsumerException e) {
      logger.atSevere().withCause(e).log("Cannot create OpenID redirect for %s" + openidIdentifier);
      return new DiscoveryResult(DiscoveryResult.Status.ERROR);
    }

    return new DiscoveryResult(aReq.getDestinationUrl(false), aReq.getParameterMap());
  }

  private boolean requestRegistration(AuthRequest aReq) {
    if (AuthRequest.SELECT_ID.equals(aReq.getIdentity())) {
      // We don't know anything about the identity, as the provider
      // will offer the user a way to indicate their identity. Skip
      // any database query operation and assume we must ask for the
      // registration information, in case the identity is new to us.
      //
      return true;
    }

    // We might already have this account on file. Look for it.
    //
    try {
      return !accountManager.lookup(aReq.getIdentity()).isPresent();
    } catch (AccountException e) {
      logger.atWarning().withCause(e).log("Cannot determine if user account exists");
      return true;
    }
  }

  /** Called by {@link OpenIdLoginServlet} doGet, doPost */
  void doAuth(HttpServletRequest req, HttpServletResponse rsp) throws Exception {
    if (OMODE_CANCEL.equals(req.getParameter(OPENID_MODE))) {
      cancel(req, rsp);
      return;
    }

    // Process the authentication response.
    //
    final SignInMode mode = signInMode(req);
    final String openidIdentifier = req.getParameter("openid.identity");
    final String claimedIdentifier = req.getParameter(P_CLAIMED);
    final String returnToken = req.getParameter(P_TOKEN);
    final boolean remember = "1".equals(req.getParameter(P_REMEMBER));
    final String rediscoverIdentifier =
        claimedIdentifier != null ? claimedIdentifier : openidIdentifier;
    final State state;

    if (!isAllowedOpenID(rediscoverIdentifier)
        || !isAllowedOpenID(openidIdentifier)
        || (claimedIdentifier != null && !isAllowedOpenID(claimedIdentifier))) {
      cancelWithError(req, rsp, "Provider not allowed");
      return;
    }

    state = init(req, rediscoverIdentifier, mode, remember, returnToken);
    if (state == null) {
      // Re-discovery must have failed, we can't run a login.
      //
      cancel(req, rsp);
      return;
    }

    final String returnTo = req.getParameter("openid.return_to");
    if (returnTo != null && returnTo.contains("openid.rpnonce=")) {
      // Some providers (claimid.com) seem to embed these request
      // parameters into our return_to URL, and then give us them
      // in the return_to request parameter. But not all.
      //
      state.retTo.put("openid.rpnonce", req.getParameter("openid.rpnonce"));
      state.retTo.put("openid.rpsig", req.getParameter("openid.rpsig"));
    }

    final VerificationResult result =
        manager.verify(
            state.retTo.toString(), new ParameterList(req.getParameterMap()), state.discovered);
    if (result.getVerifiedId() == null /* authentication failure */) {
      if ("Nonce verification failed.".equals(result.getStatusMsg())) {
        // We might be suffering from clock skew on this system.
        //
        logger.atSevere().log(
            "OpenID failure: %s  Likely caused by clock skew on this server,"
                + " install/configure NTP.",
            result.getStatusMsg());
        cancelWithError(req, rsp, result.getStatusMsg());

      } else if (result.getStatusMsg() != null) {
        // Authentication failed.
        //
        logger.atSevere().log("OpenID failure: %s", result.getStatusMsg());
        cancelWithError(req, rsp, result.getStatusMsg());

      } else {
        // Assume authentication was canceled.
        //
        cancel(req, rsp);
      }
      return;
    }

    final Message authRsp = result.getAuthResponse();
    SRegResponse sregRsp = null;
    FetchResponse fetchRsp = null;

    if (0 <= papeMaxAuthAge) {
      PapeResponse ext;
      boolean unsupported = false;

      try {
        ext = (PapeResponse) authRsp.getExtension(PapeMessage.OPENID_NS_PAPE);
      } catch (MessageException err) {
        // Far too many providers are unable to provide PAPE extensions
        // right now. Instead of blocking all of them log the error and
        // let the authentication complete anyway.
        //
        logger.atSevere().withCause(err).log("Invalid PAPE response from %s", openidIdentifier);
        unsupported = true;
        ext = null;
      }
      if (!unsupported && ext == null) {
        logger.atSevere().log("No PAPE extension response from %s", openidIdentifier);
        cancelWithError(req, rsp, "OpenID provider does not support PAPE.");
        return;
      }
    }

    if (authRsp.hasExtension(SRegMessage.OPENID_NS_SREG)) {
      final MessageExtension ext = authRsp.getExtension(SRegMessage.OPENID_NS_SREG);
      if (ext instanceof SRegResponse) {
        sregRsp = (SRegResponse) ext;
      }
    }

    if (authRsp.hasExtension(AxMessage.OPENID_NS_AX)) {
      final MessageExtension ext = authRsp.getExtension(AxMessage.OPENID_NS_AX);
      if (ext instanceof FetchResponse) {
        fetchRsp = (FetchResponse) ext;
      }
    }

    final com.google.gerrit.server.account.AuthRequest areq =
        new com.google.gerrit.server.account.AuthRequest(ExternalId.Key.parse(openidIdentifier));

    if (sregRsp != null) {
      areq.setDisplayName(sregRsp.getAttributeValue("fullname"));
      areq.setEmailAddress(sregRsp.getAttributeValue("email"));

    } else if (fetchRsp != null) {
      final String firstName = fetchRsp.getAttributeValue("FirstName");
      final String lastName = fetchRsp.getAttributeValue("LastName");
      final StringBuilder n = new StringBuilder();
      if (firstName != null && firstName.length() > 0) {
        n.append(firstName);
      }
      if (lastName != null && lastName.length() > 0) {
        if (n.length() > 0) {
          n.append(' ');
        }
        n.append(lastName);
      }
      areq.setDisplayName(n.length() > 0 ? n.toString() : null);
      areq.setEmailAddress(fetchRsp.getAttributeValue("Email"));
    }

    if (openIdDomains != null && !openIdDomains.isEmpty()) {
      // Administrator limited email domains, which can be used for OpenID.
      // Login process will only work if the passed email matches one
      // of these domains.
      //
      final String email = areq.getEmailAddress();
      int emailAtIndex = email.lastIndexOf('@');
      if (emailAtIndex >= 0 && emailAtIndex < email.length() - 1) {
        final String emailDomain = email.substring(emailAtIndex);

        boolean match = false;
        for (String domain : openIdDomains) {
          if (emailDomain.equalsIgnoreCase(domain)) {
            match = true;
            break;
          }
        }

        if (!match) {
          logger.atSevere().log("Domain disallowed: %s", emailDomain);
          cancelWithError(req, rsp, "Domain disallowed");
          return;
        }
      }
    }

    if (claimedIdentifier != null) {
      // The user used a claimed identity which has delegated to the verified
      // identity we have in our AuthRequest above. We still should have a
      // link between the two, so set one up if not present.
      //
      Optional<Account.Id> claimedId = accountManager.lookup(claimedIdentifier);
      Optional<Account.Id> actualId = accountManager.lookup(areq.getExternalIdKey().get());

      if (claimedId.isPresent() && actualId.isPresent()) {
        if (claimedId.get().equals(actualId.get())) {
          // Both link to the same account, that's what we expected.
        } else {
          // This is (for now) a fatal error. There are two records
          // for what might be the same user.
          //
          logger.atSevere().log(
              "OpenID accounts disagree over user identity:\n"
                  + "  Claimed ID: %s is %s\n"
                  + "  Delgate ID: %s is %s",
              claimedId.get(), claimedIdentifier, actualId.get(), areq.getExternalIdKey());
          cancelWithError(req, rsp, "Contact site administrator");
          return;
        }

      } else if (!claimedId.isPresent() && actualId.isPresent()) {
        // Older account, the actual was already created but the claimed
        // was missing due to a bug in Gerrit. Link the claimed.
        //
        final com.google.gerrit.server.account.AuthRequest linkReq =
            new com.google.gerrit.server.account.AuthRequest(
                ExternalId.Key.parse(claimedIdentifier));
        linkReq.setDisplayName(areq.getDisplayName());
        linkReq.setEmailAddress(areq.getEmailAddress());
        accountManager.link(actualId.get(), linkReq);

      } else if (claimedId.isPresent() && !actualId.isPresent()) {
        // Claimed account already exists, but it smells like the user has
        // changed their delegate to point to a different provider. Link
        // the new provider.
        //
        accountManager.link(claimedId.get(), areq);

      } else {
        // Both are null, we are going to create a new account below.
      }
    }

    try {
      final com.google.gerrit.server.account.AuthResult arsp;
      switch (mode) {
        case REGISTER:
        case SIGN_IN:
          arsp = accountManager.authenticate(areq);

          final Cookie lastId = new Cookie(OpenIdUrls.LASTID_COOKIE, "");
          lastId.setPath(req.getContextPath() + "/login/");
          if (remember) {
            lastId.setValue(rediscoverIdentifier);
            lastId.setMaxAge(LASTID_AGE);
          } else {
            lastId.setMaxAge(0);
          }
          rsp.addCookie(lastId);
          webSession.get().login(arsp, remember);
          if (arsp.isNew() && claimedIdentifier != null) {
            final com.google.gerrit.server.account.AuthRequest linkReq =
                new com.google.gerrit.server.account.AuthRequest(
                    ExternalId.Key.parse(claimedIdentifier));
            linkReq.setDisplayName(areq.getDisplayName());
            linkReq.setEmailAddress(areq.getEmailAddress());
            accountManager.link(arsp.getAccountId(), linkReq);
          }
          callback(arsp.isNew(), req, rsp);
          break;

        case LINK_IDENTIY:
          {
            arsp = accountManager.link(identifiedUser.get().getAccountId(), areq);
            webSession.get().login(arsp, remember);
            callback(false, req, rsp);
            break;
          }
      }
    } catch (AccountException e) {
      logger.atSevere().withCause(e).log("OpenID authentication failure");
      cancelWithError(req, rsp, "Contact site administrator");
    }
  }

  private boolean isSignIn(SignInMode mode) {
    switch (mode) {
      case SIGN_IN:
      case REGISTER:
        return true;
      case LINK_IDENTIY:
      default:
        return false;
    }
  }

  private static SignInMode signInMode(HttpServletRequest req) {
    try {
      return SignInMode.valueOf(req.getParameter(P_MODE));
    } catch (RuntimeException e) {
      return SignInMode.SIGN_IN;
    }
  }

  private void callback(final boolean isNew, HttpServletRequest req, HttpServletResponse rsp)
      throws IOException {
    String token = req.getParameter(P_TOKEN);
    if (token == null || token.isEmpty() || token.startsWith("/SignInFailure,")) {
      token = PageLinks.MINE;
    }

    final StringBuilder rdr = new StringBuilder();
    rdr.append(urlProvider.get(req));
    String nextToken = Url.decode(token);
    String registerUri = PageLinks.REGISTER + "/";
    if (isNew && !token.startsWith(registerUri)) {
      rdr.append('#' + registerUri);
      if (nextToken.startsWith("#")) {
        // Need to strip the leading # off the token to fix registration page redirect
        nextToken = nextToken.substring(1);
      }
    }
    rdr.append(nextToken);
    rsp.sendRedirect(rdr.toString());
  }

  private void cancel(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
    if (isSignIn(signInMode(req))) {
      webSession.get().logout();
    }
    callback(false, req, rsp);
  }

  private void cancelWithError(
      final HttpServletRequest req, HttpServletResponse rsp, String errorDetail)
      throws IOException {
    final SignInMode mode = signInMode(req);
    if (isSignIn(mode)) {
      webSession.get().logout();
    }
    final StringBuilder rdr = new StringBuilder();
    rdr.append(urlProvider.get(req));
    rdr.append('#');
    rdr.append("SignInFailure");
    rdr.append(',');
    rdr.append(mode.name());
    rdr.append(',');
    rdr.append(errorDetail != null ? KeyUtil.encode(errorDetail) : "");
    rsp.sendRedirect(rdr.toString());
  }

  private State init(
      HttpServletRequest req,
      final String openidIdentifier,
      final SignInMode mode,
      final boolean remember,
      final String returnToken) {
    final List<?> list;
    try {
      list = manager.discover(openidIdentifier);
    } catch (DiscoveryException e) {
      logger.atSevere().withCause(e).log("Cannot discover OpenID %s", openidIdentifier);
      return null;
    }
    if (list == null || list.isEmpty()) {
      return null;
    }

    final String contextUrl = urlProvider.get(req);
    final DiscoveryInformation discovered = manager.associate(list);
    final UrlEncoded retTo = new UrlEncoded(contextUrl + RETURN_URL);
    retTo.put(P_MODE, mode.name());
    if (returnToken != null && returnToken.length() > 0) {
      retTo.put(P_TOKEN, returnToken);
    }
    if (remember) {
      retTo.put(P_REMEMBER, "1");
    }
    if (discovered.hasClaimedIdentifier()) {
      retTo.put(P_CLAIMED, discovered.getClaimedIdentifier().getIdentifier());
    }
    return new State(discovered, retTo, contextUrl);
  }

  boolean isAllowedOpenID(String id) {
    for (OpenIdProviderPattern pattern : allowedOpenIDs) {
      if (pattern.matches(id)) {
        return true;
      }
    }
    return false;
  }

  private static class State {
    final DiscoveryInformation discovered;
    final UrlEncoded retTo;
    final String contextUrl;

    State(DiscoveryInformation d, UrlEncoded r, String c) {
      discovered = d;
      retTo = r;
      contextUrl = c;
    }
  }
}
