Google App Engine 中的 Spring Security

工程 | Luke Taylor | 2010年8月2日 | ...

Spring Security 以其高度可定制性而闻名,因此,在我第一次尝试使用 Google App Engine 时,我决定创建一个简单的应用程序,该应用程序将通过实现一些核心 Spring Security 接口来探索 GAE 功能的使用。在本文中,我们将看到如何

  • 使用 Google 帐户进行身份验证。
  • 在用户访问受保护资源时实现“按需”身份验证。
  • 使用应用程序特定的角色补充 Google 帐户中的信息。
  • 使用原生 API 将用户帐户数据存储在 App Engine 数据存储区中。
  • 根据分配给用户的角色设置访问控制限制。
  • 禁用特定用户的帐户以阻止访问。

您应该已经熟悉将应用程序部署到 GAE。启动和运行基本应用程序不需要很长时间,您可以在 GAE 网站 上找到许多关于此方面的指南。

示例应用程序

该应用程序非常简单,使用 Spring MVC 构建。在应用程序根目录部署了一个欢迎页面,您可以在身份验证并注册应用程序后进入“主页”。您可以尝试在 GAE 中部署的版本 此处

注册的用户存储为 GAE 数据存储区实体。在首次身份验证时,新用户将重定向到注册页面,他们可以在其中输入自己的姓名。注册后,可以将用户帐户标记为数据存储区中的“已禁用”,即使用户已通过 GAE 进行身份验证,也不允许他们使用该应用程序。

Spring Security 背景

我们假设您已经熟悉 Spring Security 的命名空间配置,并且理想情况下对核心接口及其交互方式有一定的了解。基础知识在参考手册的 技术概述 章节中进行了介绍。如果您也熟悉 Spring Security 的内部机制,您就会知道诸如基于表单的登录之类的 Web 身份验证机制是使用 servlet过滤器AuthenticationEntryPoint实现的。该AuthenticationEntryPoint在匿名用户尝试访问受保护资源时驱动身份验证过程,并且过滤器从后续请求(例如登录表单的提交)中提取身份验证信息,对用户进行身份验证并为用户的会话构建安全上下文。

过滤器将身份验证决策委托给AuthenticationManager,后者配置了一个AuthenticationProviderbean 列表,这些 bean 中的任何一个都可以对用户进行身份验证,或者如果身份验证失败则引发异常。

在基于表单的登录的情况下,该AuthenticationEntryPoint只是将用户重定向到登录页面。身份验证过滤器(在本例中为UsernamePasswordAuthenticationFilter)从提交的 POST 请求中提取用户名和密码。它们存储在Authentication对象中并传递给AuthenticationProvider,后者通常会将用户的密码与存储在数据库或 LDAP 服务器中的密码进行比较。

这是组件之间基本交互。这如何应用于 GAE 应用程序?

Google 帐户身份验证

当然,没有什么可以阻止您在 GAE 中部署标准的 Spring Security 应用程序(当然,不带 JDBC 支持),但是如果您想使用 GAE 提供的 API 以允许用户通过其通常的 Google 登录进行身份验证怎么办?这实际上非常简单,大部分工作由 GAE 的 UserService 处理,该服务具有生成外部登录 URL 的方法。您提供一个目标,用户在身份验证后将返回到该目标,从而允许他们继续使用应用程序。我们可以使用它在网页中呈现登录链接,但我们也可以在自定义AuthenticationEntryPoint:

import com.google.appengine.api.users.UserService;
import com.google.appengine.api.users.UserServiceFactory;

public class GoogleAccountsAuthenticationEntryPoint implements AuthenticationEntryPoint {
  public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
      throws IOException, ServletException {
    UserService userService = UserServiceFactory.getUserService();

    response.sendRedirect(userService.createLoginURL(request.getRequestURI()));
  }
}

中直接重定向到它。如果将此添加到我们的配置中,使用 Spring Security 命名空间为此目的提供的特定挂钩,我们将得到如下内容


<b:beans xmlns="http://www.springframework.org/schema/security"
        xmlns:b="http://www.springframework.org/schema/beans"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
        http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.1.xsd">

    <http use-expressions="true" entry-point-ref="gaeEntryPoint">
        <intercept-url pattern="/" access="permitAll" />
        <intercept-url pattern="/**" access="hasRole('USER')" />
    </http>

    <b:bean id="gaeEntryPoint" class="samples.gae.security.GoogleAccountsAuthenticationEntryPoint" />
    ...
</b:beans>

在这里,我们已将所有 URL 配置为需要“USER”角色,除了 Web 应用程序根目录。当用户首次尝试访问任何其他页面时,他们将被重定向到 Google 帐户登录屏幕

Google App Engine login page

现在我们需要添加过滤器 bean,当用户被 GAE 重定向回我们的站点并登录到 Google 帐户时,该过滤器将设置安全上下文。这是身份验证过滤器代码

public class GaeAuthenticationFilter extends GenericFilterBean {
  private static final String REGISTRATION_URL = "/register.htm";
  private AuthenticationDetailsSource ads = new WebAuthenticationDetailsSource();
  private AuthenticationManager authenticationManager;
  private AuthenticationFailureHandler failureHandler = new SimpleUrlAuthenticationFailureHandler();

  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    if (authentication == null) {
      // User isn't authenticated. Check if there is a Google Accounts user
      User googleUser = UserServiceFactory.getUserService().getCurrentUser();

      if (googleUser != null) {
        // User has returned after authenticating through GAE. Need to authenticate to Spring Security.
        PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(googleUser, null);
        token.setDetails(ads.buildDetails(request));

        try {
          authentication = authenticationManager.authenticate(token);
          // Setup the security context
          SecurityContextHolder.getContext().setAuthentication(authentication);
          // Send new users to the registration page.
          if (authentication.getAuthorities().contains(AppRole.NEW_USER)) {
            ((HttpServletResponse) response).sendRedirect(REGISTRATION_URL);
              return;
          }
        } catch (AuthenticationException e) {
         // Authentication information was rejected by the authentication manager
          failureHandler.onAuthenticationFailure((HttpServletRequest)request, (HttpServletResponse)response, e);
          return;
        }
      }
    }

    chain.doFilter(request, response);
  }

  public void setAuthenticationManager(AuthenticationManager authenticationManager) {
    this.authenticationManager = authenticationManager;
  }

  public void setFailureHandler(AuthenticationFailureHandler failureHandler) {
    this.failureHandler = failureHandler;
  }
}

我们从头开始实现了过滤器,使其更容易理解,并避免了继承现有类的复杂性。如果用户当前未经身份验证(从 Spring Security 的角度来看),则过滤器检查 GAE 用户是否存在(再次利用 GAE 的UserService)。如果找到用户,则将其打包到合适的身份验证令牌对象中(此处出于方便起见使用了 Spring Security 的PreAuthenticatedAuthenticationToken)并将其传递给AuthenticationManager以由 Spring Security 进行身份验证。此时,新用户将被重定向到注册页面。

自定义身份验证提供程序

在这种情况下,我们没有以传统意义上确定用户是否为其声称身份的方式对用户进行身份验证。Google 帐户已经解决了这个问题。我们只对检查用户是否是应用程序角度的有效用户感兴趣。这种情况类似于将 Spring Security 与 CAS 或 OpenID 等单点登录系统一起使用。身份验证提供程序需要检查用户的帐户状态并加载任何其他信息(例如应用程序特定的角色)。在我们的示例中,我们还有一个“未注册”用户的概念,该用户以前从未使用过该应用程序。如果应用程序不认识该用户,则将为其分配一个临时的“NEW_USER”角色,该角色仅允许他们访问注册 URL。注册后,将为其分配“USER”角色。

AuthenticationProvider实现与UserRegistry交互以存储和检索GaeUser对象(两者特定于此示例)


public interface UserRegistry {
  GaeUser findUser(String userId);
  void registerUser(GaeUser newUser);
  void removeUser(String userId);
}

public class GaeUser implements Serializable {
  private final String userId;
  private final String email;
  private final String nickname;
  private final String forename;
  private final String surname;
  private final Set<AppRole> authorities;
  private final boolean enabled;

// Constructors and accessors omitted
...

userId是 Google 帐户分配的唯一 ID。电子邮件和昵称也从 GAE 用户那里获取。名字和姓氏在注册表单中输入。除非通过 GAE 数据存储区管理控制台直接修改,否则启用标志设置为“true”。AppRole是 Spring Security 的GrantedAuthority的实现,作为枚举


public enum AppRole implements GrantedAuthority {
    ADMIN (0),
    NEW_USER (1),
    USER (2);

    private int bit;

    AppRole(int bit) {
        this.bit = bit;
    }

    public String getAuthority() {
        return toString();
    }
}

角色如上所述分配。然后AuthenticationProvider如下所示


public class GoogleAccountsAuthenticationProvider implements AuthenticationProvider {
    private UserRegistry userRegistry;

    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        User googleUser = (User) authentication.getPrincipal();

        GaeUser user = userRegistry.findUser(googleUser.getUserId());

        if (user == null) {
            // User not in registry. Needs to register
            user = new GaeUser(googleUser.getUserId(), googleUser.getNickname(), googleUser.getEmail());
        }

        if (!user.isEnabled()) {
            throw new DisabledException("Account is disabled");
        }

        return new GaeUserAuthentication(user, authentication.getDetails());
    }

    public final boolean supports(Class<?> authentication) {
        return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public void setUserRegistry(UserRegistry userRegistry) {
        this.userRegistry = userRegistry;
    }
}

GaeUserAuthentication类是 Spring Security 的Authentication接口的一个非常简单的实现,它将GaeUser对象作为主体。如果您以前自定义过 Spring Security,您可能想知道为什么我们没有在任何地方实现UserDetailsService以及为什么主体不是UserDetails实例。简单的答案是您不必这样做——Spring Security 通常不关心对象类型的具体内容,并且在这里我们选择直接实现AuthenticationProvider接口作为最简单的选项。

GAE 数据源用户注册表

现在我们需要UserRegistry的实现,该实现使用 GAE 的数据存储区。

import com.google.appengine.api.datastore.*;
import org.springframework.security.core.GrantedAuthority;
import samples.gae.security.AppRole;
import java.util.*;

public class GaeDatastoreUserRegistry implements UserRegistry {
    private static final String USER_TYPE = "GaeUser";
    private static final String USER_FORENAME = "forename";
    private static final String USER_SURNAME = "surname";
    private static final String USER_NICKNAME = "nickname";
    private static final String USER_EMAIL = "email";
    private static final String USER_ENABLED = "enabled";
    private static final String USER_AUTHORITIES = "authorities";

    public GaeUser findUser(String userId) {
        Key key = KeyFactory.createKey(USER_TYPE, userId);
        DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();

        try {
            Entity user = datastore.get(key);

            long binaryAuthorities = (Long)user.getProperty(USER_AUTHORITIES);
            Set<AppRole> roles = EnumSet.noneOf(AppRole.class);

            for (AppRole r : AppRole.values()) {
                if ((binaryAuthorities & (1 << r.getBit())) != 0) {
                    roles.add(r);
                }
            }

            GaeUser gaeUser = new GaeUser(
                    user.getKey().getName(),
                    (String)user.getProperty(USER_NICKNAME),
                    (String)user.getProperty(USER_EMAIL),
                    (String)user.getProperty(USER_FORENAME),
                    (String)user.getProperty(USER_SURNAME),
                    roles,
                    (Boolean)user.getProperty(USER_ENABLED));

            return gaeUser;

        } catch (EntityNotFoundException e) {
            logger.debug(userId + " not found in datastore");
            return null;
        }
    }

    public void registerUser(GaeUser newUser) {
        Key key = KeyFactory.createKey(USER_TYPE, newUser.getUserId());
        Entity user = new Entity(key);
        user.setProperty(USER_EMAIL, newUser.getEmail());
        user.setProperty(USER_NICKNAME, newUser.getNickname());
        user.setProperty(USER_FORENAME, newUser.getForename());
        user.setProperty(USER_SURNAME, newUser.getSurname());
        user.setUnindexedProperty(USER_ENABLED, newUser.isEnabled());

        Collection<? extends GrantedAuthority> roles = newUser.getAuthorities();

        long binaryAuthorities = 0;

        for (GrantedAuthority r : roles) {
            binaryAuthorities |= 1 << ((AppRole)r).getBit();
        }

        user.setUnindexedProperty(USER_AUTHORITIES, binaryAuthorities);

        DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
        datastore.put(user);
    }

    public void removeUser(String userId) {
        DatastoreService datastore = DatastoreServiceFactory.getDatastoreService();
        Key key = KeyFactory.createKey(USER_TYPE, userId);

        datastore.delete(key);
    }
}

如前所述,该示例使用枚举表示应用程序角色。分配给用户的角色(权限)存储为EnumSet. EnumSet非常节省资源,用户的角色可以存储为单个long值,从而可以更简单地与数据存储区 API 交互。为此,我们为每个角色分配了一个单独的“位”属性。

用户注册

用户注册控制器包含以下方法,该方法处理注册表单的提交。


    @Autowired
    private UserRegistry registry;

    @RequestMapping(method = RequestMethod.POST)
    public String register(@Valid RegistrationForm form, BindingResult result) {
        if (result.hasErrors()) {
            return null;
        }

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        GaeUser currentUser = (GaeUser)authentication.getPrincipal();
        Set<AppRole> roles = EnumSet.of(AppRole.USER);

        if (UserServiceFactory.getUserService().isUserAdmin()) {
            roles.add(AppRole.ADMIN);
        }

        GaeUser user = new GaeUser(currentUser.getUserId(), currentUser.getNickname(), currentUser.getEmail(),
                form.getForename(), form.getSurname(), roles, true);

        registry.registerUser(user);

        // Update the context with the full authentication
        SecurityContextHolder.getContext().setAuthentication(new GaeUserAuthentication(user, authentication.getDetails()));

        return "redirect:/home.htm";
    }

使用提供的名字和姓氏创建用户,并创建一组新的角色。如果 GAE 指示当前用户是应用程序的管理员,这也可能包括“ADMIN”角色。然后将其存储在用户注册表中,并使用更新的Authentication对象填充安全上下文,以确保 Spring Security 了解新的角色信息并相应地应用其访问控制约束。

最终应用程序配置

安全应用程序上下文现在如下所示


    <http use-expressions="true" entry-point-ref="gaeEntryPoint">
        <intercept-url pattern="/" access="permitAll" />
        <intercept-url pattern="/register.htm*" access="hasRole('NEW_USER')" />
        <intercept-url pattern="/**" access="hasRole('USER')" />
        <custom-filter position="PRE_AUTH_FILTER" ref="gaeFilter" />
    </http>

    <b:bean id="gaeEntryPoint" class="samples.gae.security.GoogleAccountsAuthenticationEntryPoint" />

    <b:bean id="gaeFilter" class="samples.gae.security.GaeAuthenticationFilter">
        <b:property name="authenticationManager" ref="authenticationManager"/>
    </b:bean>

    <authentication-manager alias="authenticationManager">
        <authentication-provider ref="gaeAuthenticationProvider"/>
    </authentication-manager>

    <b:bean id="gaeAuthenticationProvider" class="samples.gae.security.GoogleAccountsAuthenticationProvider">
        <b:property name="userRegistry" ref="userRegistry" />
    </b:bean>

    <b:bean id="userRegistry" class="samples.gae.users.GaeDatastoreUserRegistry" />

您可以看到我们使用custom-filter命名空间元素插入了我们的过滤器,声明了提供程序和用户注册表,并将它们全部连接起来。我们还添加了注册控制器的 URL,新用户可以访问该 URL。

结论

Spring Security 多年来一直证明它足够灵活,可以在许多不同的场景中增加价值,在 Google App Engine 中的部署也不例外。还值得记住的是,自己实现一些接口(就像我们在这里所做的那样)通常比尝试使用不完全适合的现有类要好。您最终可能会获得一个更简洁的解决方案,该解决方案更符合您的需求。

这里的重点是如何在启用了 Spring Security 的应用程序中使用 Google App Engine API。我们没有介绍应用程序工作方式的其他所有细节,但我鼓励您查看代码并亲自了解。如果您是 GAE 专家,那么我们随时欢迎您提出改进建议!

示例代码已在 3.1 代码库中,因此您可以从 我们的 Git 存储库 中检出它。Spring Security 3.1 的第一个里程碑也应该在本月晚些时候发布。

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,以助您快速进步。

了解更多

获取支持

Tanzu Spring 在一个简单的订阅中提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件。

了解更多

即将举行的活动

查看 Spring 社区中所有即将举行的活动。

查看全部