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 實現的Filter和一個AuthenticationEntryPoint。該AuthenticationEntryPoint在匿名使用者嘗試訪問安全資源時驅動認證過程,而過濾器從後續請求(例如提交登入表單)中提取認證資訊,認證使用者併為使用者會話構建安全上下文。

過濾器將認證決策委託給AuthenticationManager,它配置了一系列AuthenticationProviderbean,其中任何一個都可以認證使用者,或者在認證失敗時丟擲異常。

對於基於表單的登入,AuthenticationEntryPoint只是將使用者重定向到登入頁面。認證過濾器(在此情況下是UsernamePasswordAuthenticationFilter)從提交的 POST 請求中提取使用者名稱和密碼。它們儲存在一個Authentication物件中,並傳遞給一個AuthenticationProvider,它通常會將使用者的密碼與儲存在資料庫或 LDAP 伺服器中的密碼進行比較。

這就是元件之間的基本互動。這如何應用於 GAE 應用程式呢?

Google Accounts 認證

當然,您完全可以在 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>

在這裡,我們配置了除了 web 應用程式根目錄之外的所有 URL 都需要“USER”角色。使用者在首次嘗試訪問任何其他頁面時將被重定向到 Google Accounts 登入螢幕

Google App Engine login page

現在我們需要新增一個過濾器 bean,當用戶透過 GAE 登入 Google Accounts 後重定向回我們的網站時,該 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 Accounts 分配的唯一 ID。電子郵件和暱稱也從 GAE 使用者那裡獲取。名字和姓氏在登錄檔單中輸入。除非透過 GAE 資料儲存管理控制檯直接修改,“enabled”標誌設定為“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物件作為 principal。如果您之前對 Spring Security 進行過一些定製,您可能會想知道為什麼我們在此處沒有實現UserDetailsService,以及為什麼 principal 不是一個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 的互動。為此,我們為每個角色分配了一個單獨的“bit”屬性。

使用者註冊

使用者註冊控制器包含以下方法,用於處理登錄檔單的提交。


    @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名稱空間元素插入了我們的過濾器,聲明瞭 provider 和 user registry,並將它們全部連線起來。我們還為註冊控制器添加了一個 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 社群的所有近期活動。

檢視全部