Google App Engine 中的 Spring Security

工程 | Luke Taylor | 2010年08月02日 | ...

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 实现的Filter和一个AuthenticationEntryPoint结尾的包中。AuthenticationEntryPoint驱动认证过程,当匿名用户试图访问受保护资源时,该过滤器从后续请求(例如提交登录表单)中提取认证信息,认证用户并为用户会话构建安全上下文。

过滤器将认证决策委托给AuthenticationManager,它配置了一个AuthenticationProviderbean 列表,其中任何一个都可以认证用户,或者在认证失败时抛出异常。

在基于表单的登录情况下,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 账户并重定向回我们的网站时,该过滤器 bean 将设置安全上下文。以下是身份验证过滤器代码:

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. EnumSets 非常节省资源,用户的角色可以存储为单个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 社区所有即将举行的活动。

查看所有