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 實作的FilterAuthenticationEntryPointAuthenticationEntryPoint當匿名使用者嘗試存取受保護的資源時,會驅動驗證過程,而篩選器會從後續請求(例如提交登入表單)中擷取驗證資訊,驗證使用者並為使用者的會話建立安全性內容。

篩選器將驗證決策委派給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」角色,webapp 根目錄除外。當使用者首次嘗試存取任何其他頁面時,將被重新導向到 Google 帳戶登入畫面

Google App Engine login page

我們現在需要新增篩選器 Bean,當使用者透過登入 Google 帳戶被 GAE 重新導向回我們的網站時,它將設定安全性內容。以下是驗證篩選器程式碼

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 使用者是否存在(再次使用 GAEUserService)。如果找到使用者,則會將其封裝在合適的驗證權杖物件中(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 社群中所有即將舉辦的活動。

檢視全部