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 社群所有即將舉行的活動。

檢視所有