/**
* Copyright 2015 Google Inc. All Rights Reserved.
*
* 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.apphosting.vmruntime.jetty9;
import com.google.appengine.api.users.User;
import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;
import com.google.apphosting.api.ApiProxy;
import com.google.apphosting.api.ApiProxy.Environment;
import com.google.apphosting.vmruntime.VmApiProxyEnvironment;
import org.eclipse.jetty.security.ConstraintSecurityHandler;
import org.eclipse.jetty.security.DefaultIdentityService;
import org.eclipse.jetty.security.IdentityService;
import org.eclipse.jetty.security.LoginService;
import org.eclipse.jetty.security.SecurityHandler;
import org.eclipse.jetty.security.ServerAuthException;
import org.eclipse.jetty.security.UserAuthentication;
import org.eclipse.jetty.security.authentication.DeferredAuthentication;
import org.eclipse.jetty.security.authentication.LoginAuthenticator;
import org.eclipse.jetty.server.Authentication;
import org.eclipse.jetty.server.HttpChannel;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.UserIdentity;
import org.eclipse.jetty.util.URIUtil;
import java.io.IOException;
import java.security.Principal;
import java.util.Arrays;
import java.util.HashSet;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.security.auth.Subject;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
* {@code AppEngineAuthentication} is a utility class that can
* configure a Jetty {@link SecurityHandler} to integrate with the App
* Engine authentication model.
*
* <p>Specifically, it registers a custom {@link Authenticator}
* instance that knows how to redirect users to a login URL using the
* {@link UserService}, and a custom {@link UserIdentity} that is aware
* of the custom roles provided by the App Engine.
*
*/
class AppEngineAuthentication {
private static final Logger log = Logger.getLogger(
AppEngineAuthentication.class.getName());
/**
* URLs that begin with this prefix are reserved for internal use by
* App Engine. We assume that any URL with this prefix may be part
* of an authentication flow (as in the Dev Appserver).
*/
private static final String AUTH_URL_PREFIX = "/_ah/";
private static final String AUTH_METHOD = "Google Login";
private static final String REALM_NAME = "Google App Engine";
// Keep in sync with com.google.apphosting.runtime.jetty.JettyServletEngineAdapter.
private static final String SKIP_ADMIN_CHECK_ATTR =
"com.google.apphosting.internal.SkipAdminCheck";
/**
* Any authenticated user is a member of the {@code "*"} role, and
* any administrators are members of the {@code "admin"} role. Any
* other roles will be logged and ignored.
*/
private static final String USER_ROLE = "*";
private static final String ADMIN_ROLE = "admin";
/**
* Inject custom {@link LoginService} and {@link Authenticator}
* implementations into the specified {@link ConstraintSecurityHandler}.
*/
public static void configureSecurityHandler(
ConstraintSecurityHandler handler, VmRuntimeTrustedAddressChecker checker) {
LoginService loginService = new AppEngineLoginService();
LoginAuthenticator authenticator = new AppEngineAuthenticator(checker);
DefaultIdentityService identityService = new DefaultIdentityService();
// Set allowed roles.
handler.setRoles(new HashSet<String>(Arrays.asList(new String[] {USER_ROLE, ADMIN_ROLE})));
handler.setLoginService(loginService);
handler.setAuthenticator(authenticator);
handler.setIdentityService(identityService);
authenticator.setConfiguration(handler);
}
/**
* {@code AppEngineAuthenticator} is a custom {@link Authenticator}
* that knows how to redirect the current request to a login URL in
* order to authenticate the user.
*/
private static class AppEngineAuthenticator extends LoginAuthenticator {
private VmRuntimeTrustedAddressChecker checker;
/**
* Checks if the request could to to the login page.
*
* @param uri The uri requested.
*
* @return True if the uri starts with "/_ah/", false otherwise.
*/
private static boolean isLoginOrErrorPage(String uri) {
return uri.indexOf(AUTH_URL_PREFIX) == 0;
}
private AppEngineAuthenticator(VmRuntimeTrustedAddressChecker checker) {
this.checker = checker;
}
@Override
public String getAuthMethod() {
return AUTH_METHOD;
}
/**
* Validate a response. Compare to:
* j.c.g.apphosting.utils.jetty.AppEngineAuthentication.AppEngineAuthenticator.authenticate().
*
* <p>If authentication is required but the request comes from an untrusted ip, 307s the request
* back to the trusted appserver. Otherwise it will auth the request and return a login
* url if needed.
*
* <p>From org.eclipse.jetty.server.Authentication:
* @param servletRequest The request
* @param servletResponse The response
* @param mandatory True if authentication is mandatory.
* @return An Authentication. If Authentication is successful, this will be a
* {@link org.eclipse.jetty.server.Authentication.User}. If a response has been sent by
* the Authenticator (which can be done for both successful and unsuccessful
* authentications), then the result will implement
* {@link org.eclipse.jetty.server.Authentication.ResponseSent}. If Authentication is
* not mandatory, then a {@link org.eclipse.jetty.server.Authentication.Deferred} may be
* returned.
*
* @throws ServerAuthException
*/
@Override
public Authentication validateRequest(
ServletRequest servletRequest, ServletResponse servletResponse, boolean mandatory)
throws ServerAuthException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if (!mandatory) {
return new DeferredAuthentication(this);
}
String remoteAddr = request.getHeader(VmApiProxyEnvironment.REAL_IP_HEADER);
if (remoteAddr == null) {
HttpChannel channel = ((Request) request).getHttpChannel();
if (channel != null) {
remoteAddr = channel.getEndPoint().getRemoteAddress().getAddress().getHostAddress();
}
}
// Untrusted inbound ip for a login page, 307 the user to a server that we can trust.
if (!checker.isTrustedRemoteAddr(remoteAddr)) {
String redirectUrl = getThreadLocalEnvironment().getL7UnsafeRedirectUrl()
+ request.getRequestURI();
if (request.getQueryString() != null) {
redirectUrl += "?" + request.getQueryString();
}
response.setStatus(307);
response.setHeader("Location", redirectUrl);
return Authentication.SEND_CONTINUE;
}
// Trusted inbound ip, auth headers can be trusted.
String uri = request.getRequestURI();
if (uri == null) {
uri = URIUtil.SLASH;
}
// Check this before checking if there is a user logged in, so
// that we can log out properly. Specifically, watch out for
// the case where the user logs in, but as a role that isn't
// allowed to see /*. They should still be able to log out.
if (isLoginOrErrorPage(uri) && !DeferredAuthentication.isDeferred(response)) {
log.fine("Got " + uri + ", returning DeferredAuthentication to "
+ "imply authentication is in progress.");
return new DeferredAuthentication(this);
}
if (request.getAttribute(SKIP_ADMIN_CHECK_ATTR) != null) {
log.fine("Returning DeferredAuthentication because of SkipAdminCheck.");
// Warning: returning DeferredAuthentication here will bypass security restrictions!
return new DeferredAuthentication(this);
}
if (response == null) {
throw new ServerAuthException("validateRequest called with null response!!!");
}
try {
UserService userService = UserServiceFactory.getUserService();
// If the user is authenticated already, just create a
// AppEnginePrincipal or AppEngineFederatedPrincipal for them.
if (userService.isUserLoggedIn()) {
UserIdentity user = _loginService.login(null, null, null);
log.fine("authenticate() returning new principal for " + user);
if (user != null) {
return new UserAuthentication(getAuthMethod(), user);
}
}
if (DeferredAuthentication.isDeferred(response)) {
return Authentication.UNAUTHENTICATED;
}
try {
log.fine("Got " + request.getRequestURI() + " but no one was logged in, redirecting.");
String url = userService.createLoginURL(getFullURL(request));
response.sendRedirect(url);
// Tell Jetty that we've already committed a response here.
return Authentication.SEND_CONTINUE;
} catch (ApiProxy.ApiProxyException ex) {
// If we couldn't get a login URL for some reason, return a 403 instead.
log.log(Level.SEVERE, "Could not get login URL:", ex);
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return Authentication.SEND_FAILURE;
}
} catch (IOException ex) {
log.log(Level.WARNING, "Got an IOException from sendRedirect:", ex);
throw new ServerAuthException(ex);
}
}
/*
* We are not using sessions for authentication.
*/
@Override
protected HttpSession renewSession(HttpServletRequest request, HttpServletResponse response) {
log.warning("renewSession throwing an UnsupportedOperationException");
throw new UnsupportedOperationException();
}
/*
* This seems to only be used by JaspiAuthenticator, all other Authenticators return true.
*/
@Override
public boolean secureResponse(ServletRequest servletRequest, ServletResponse servletResponse,
boolean isAuthMandatory, Authentication.User user) {
return true;
}
/**
* Returns the thread local environment if it is a VmApiProxyEnvironment.
*
* @return The ThreadLocal environment or null if no VmApiProxyEnvironment is set.
*/
private VmApiProxyEnvironment getThreadLocalEnvironment() {
Environment env = ApiProxy.getCurrentEnvironment();
if (env instanceof VmApiProxyEnvironment) {
return (VmApiProxyEnvironment) env;
}
return null;
}
}
/**
* Returns the full URL of the specified request, including any query string.
*/
private static String getFullURL(HttpServletRequest request) {
StringBuffer buffer = request.getRequestURL();
if (request.getQueryString() != null) {
buffer.append('?');
buffer.append(request.getQueryString());
}
return buffer.toString();
}
/**
* {@code AppEngineLoginService} is a custom Jetty {@link LoginService} that is aware of the two
* special role names implemented by Google App Engine. Any authenticated user is a member of the
* {@code "*"} role, and any administrators are members of the {@code "admin"} role. Any other
* roles will be logged and ignored.
*/
private static class AppEngineLoginService implements LoginService {
private IdentityService identityService;
/**
* @return Get the name of the login service (aka Realm name)
*/
@Override
public String getName() {
return REALM_NAME;
}
/**
* Login a user.
*
* @param unusedUsername Not used, the username is fetched using the UserService.
* @param unusedCredentials Not used, the credentials are verified before the request gets here.
* @return A UserIdentity if the user is logged in, otherwise null
*/
@Override
public UserIdentity login(String unusedUsername, Object unusedCredentials,
ServletRequest request) {
AppEngineUserIdentity appEngineUserIdentity = loadUser();
return appEngineUserIdentity;
}
/**
* Creates a new AppEngineUserIdentity based on information retrieved from the Users API.
*
* @return A AppEngineUserIdentity if a user is logged in, or null otherwise.
*/
private AppEngineUserIdentity loadUser() {
UserService userService = UserServiceFactory.getUserService();
User engineUser = userService.getCurrentUser();
if (engineUser == null){
return null;
}
return new AppEngineUserIdentity(new AppEnginePrincipal(engineUser));
}
@Override
public IdentityService getIdentityService() {
return identityService;
}
@Override
public void logout(UserIdentity user) {
// Jetty calls this on every request -- even if user is null!
if (user != null) {
log.fine("Ignoring logout call for: " + user);
}
}
@Override
public void setIdentityService(IdentityService identityService) {
this.identityService = identityService;
}
/**
* Validate a user identity. Validate that a UserIdentity previously created by a call to
* {@link #login(String, Object)} is still valid.
*
* @param user The user to validate
* @return true if authentication has not been revoked for the user.
*/
@Override
public boolean validate(UserIdentity user) {
log.info("validate(" + user + ") throwing UnsupportedOperationException.");
throw new UnsupportedOperationException();
}
}
/**
* {@code AppEnginePrincipal} is an implementation of {@link Principal}
* that represents a logged-in Google App Engine user.
*/
public static class AppEnginePrincipal implements Principal {
private final User user;
public AppEnginePrincipal(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public String getName() {
if ((user.getFederatedIdentity() != null) && (user.getFederatedIdentity().length() > 0)) {
return user.getFederatedIdentity();
}
return user.getEmail();
}
@Override
public boolean equals(Object other) {
if (other instanceof AppEnginePrincipal) {
return user.equals(((AppEnginePrincipal) other).user);
} else {
return false;
}
}
@Override
public String toString() {
return user.toString();
}
@Override
public int hashCode() {
return user.hashCode();
}
}
/**
* {@code AppEngineUserIdentity} is an implementation of {@link UserIdentity}
* that represents a logged-in Google App Engine user.
*/
public static class AppEngineUserIdentity implements UserIdentity {
private final AppEnginePrincipal userPrincipal;
public AppEngineUserIdentity(AppEnginePrincipal userPrincipal) {
this.userPrincipal = userPrincipal;
}
/*
* Only used by jaas and jaspi.
*/
@Override
public Subject getSubject() {
log.info("getSubject() throwing UnsupportedOperationException.");
throw new UnsupportedOperationException();
}
@Override
public Principal getUserPrincipal() {
return userPrincipal;
}
@Override
public boolean isUserInRole(String role, Scope unusedScope) {
UserService userService = UserServiceFactory.getUserService();
log.fine("Checking if principal " + userPrincipal + " is in role " + role);
if (userPrincipal == null) {
log.info("isUserInRole() called with null principal.");
return false;
}
if (USER_ROLE.equals(role)) {
return true;
}
if (ADMIN_ROLE.equals(role)) {
User user = userPrincipal.getUser();
if (user.equals(userService.getCurrentUser())) {
return userService.isUserAdmin();
} else {
// TODO(user): I'm not sure this will happen in
// practice. If it does, we may need to pass an
// application's admin list down somehow.
log.severe("Cannot tell if non-logged-in user " + user + " is an admin.");
return false;
}
} else {
log.warning("Unknown role: " + role + ".");
return false;
}
}
@Override
public String toString() {
return AppEngineUserIdentity.class.getSimpleName() + "('" + userPrincipal + "')";
}
}
}