Tapestry 整合 Acegi

来源:互联网 发布:java生成订单号唯一性 编辑:程序博客网 时间:2024/04/30 10:58
If you've read a couple of my last (unanswered) posts, you'll see that I was flailing on getting Acegi and Tapestry to play nicely together-- mostly due to the fact that (a) I'm a noob and (b) Tapestry URLs are all /app?page=Blah...which makes it impossible to distinguish between a Login page you want unsecured and another page you do want secured.

So, with the caveat that there may be a lot of unnecessary configuration garbage I'm about to post, I thought I'd share the steps I went through to get this working.

My environment is: Eclipse 3.1.1, Java 5, Tapestry 4, Acegi 1.0.0 RC2, Tomcat 5.5.12

Step 1) Enable Friendly URLs in Tapestry

Before trying to get Acegi set up on your Tapestry application, you should first enable Friendly URLs to allow fine-grained control of pattern matching in the Acegi objectionDefinitionSource widgets.

Most of these instructions are stolen right out of Kent Tong Tap 4 manual.

Step A: Edit your hivemodule.xml file

<contribution configuration-id="tapestry.url.ServiceEncoders">
        <page-service-encoder id="page" extension="html" service="page"/>
        <direct-service-encoder id="direct" stateless-extension="direct" stateful-extension="sdirect"/>        
        <extension-encoder id="extension" extension="svc" after="*"/>
</contribution>

Step B: Edit your web.xml file

In addition to the standard "/app" mapping, add the url-patterns shown here. Tapestry uses a variety of different url translations to achieve direct links, services, etc. through the friendly-url paradigm.

<servlet-mapping> <servlet-name>edis3-admin</servlet-name> <url-pattern>/app</url-pattern> </servlet-mapping>

<servlet-mapping> <servlet-name>edis3-admin</servlet-name> <url-pattern>*.html</url-pattern> </servlet-mapping>

<servlet-mapping> <servlet-name>edis3-admin</servlet-name> <url-pattern>*.direct</url-pattern> </servlet-mapping>

<servlet-mapping> <servlet-name>edis3-admin</servlet-name> <url-pattern>*.sdirect</url-pattern> </servlet-mapping>

<servlet-mapping> <servlet-name>edis3-admin</servlet-name> <url-pattern>*.svc</url-pattern> </servlet-mapping>

Step 2) Add Acegi Spring configurations

This is bulk of the effort here, but it's mostly just XML file editing and small configuration differences to fit in your environment.

Step A: Add Acegi configurations to your web.xml

Add this filter and then map it to your application. Note that many examples you may see will have the targetClass defined to be AuthenticationProcessingFilter...don't use that one; it's just a subset of the larger filter chain we'll set up in our application context.

<!-- Note: this replaces your original Tapestry filter and filter-mapping -->
<filter> <filter-name>Acegi Filter Chain Proxy</filter-name> <filter-class>org.acegisecurity.util.FilterToBeanProxy</filter-class>
<init-param>
    <param-name>targetClass</param-name>        
    <param-value>org.acegisecurity.util.FilterChainProxy</param-value>
</init-param>
</filter>

<filter-mapping>
    <filter-name>Acegi Filter Chain Proxy</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

Still in the web.xml, add an additional file (in my case, application-context-acegi.xml) to your contextConfigLocation that we'll use to store the Acegi configuration information. I also found it necessary to intercept the 403 error code to provide seamless integration with my application when someone hits and a page they're not authorized to see.

<context-param>
    <param-name>contextConfigLocation</param-name>    
    <param-value>classpath:edis3-ws-client-context.xml, 
                                classpath:application-context-acegi.xml
    </param-value>
</context-param>

<error-page>
    <error-code>403</error-code>
    <location>/AccessDenied.html</location>
</error-page>

Step B: Create the application-context-acegi.xml file

I created a new file in the WEB-INF/classes location of my project and added the following configuration settings, which I've commented in the code:


<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN//EN" "http://www.springframework.org/dtd/spring-beans.dtd">

<beans>

   <!-- ======================== FILTER CHAIN ======================= -->

    <!--  Note: I got rid of the 'rememberMe' and 'switchUser' filters you see in a lot of examples.  They were confusing the debugging, and now that I understand what's going on, they'll be easy to add in later if I need them -->
    <bean id="filterChainProxy" class="org.acegisecurity.util.FilterChainProxy">
      <property name="filterInvocationDefinitionSource">
         <value>

            CONVERT_URL_TO_LOWERCASE_BEFORE_COMPARISON
            PATTERN_TYPE_APACHE_ANT
                     
/**=httpSessionContextIntegrationFilter,authenticationProcessingFilter,basicProcessingFilter,anonymousProcessingFilter,
exceptionTranslationFilter,filterInvocationInterceptor

         </value>
      </property>
    </bean>

   <!-- ======================== AUTHENTICATION ======================= -->

   <bean id="authenticationManager" class="org.acegisecurity.providers.ProviderManager">
      <property name="providers">
         <list>
            <ref local="daoAuthenticationProvider"/>
            <ref local="anonymousAuthenticationProvider"/>
         </list>
      </property>
   </bean>

 <!--  NOTE NOTE NOTE NOTE      BE CAREFUL HERE
         The inMemoryDaoImpl DOES NOT support the passwordEncoder -->
    <bean id="inMemoryDaoImpl" class="org.acegisecurity.userdetails.memory.InMemoryDaoImpl">
        <property name="userMap">
            <value>
                tom=tvaughan,ROLE_USER,ROLE_SYSTEM_ADMIN
                sue=stillery,ROLE_USER,ROLE_SYSTEM_ADMIN               
                carlos=cfernandez,ROLE_USER,ROLE_USER_ADMIN
                joel=jmoeller,ROLE_USER,ROLE_USER_ADMIN
                tony=tgiaccone,ROLE_USER,ROLE_SERVICE_LIST_ADMIN
                jack=jrodriguez,ROLE_USER,ROLE_INVESTIGATION_ADMIN
                walter=wkelly,ROLE_USER,ROLE_INVESTIGATION_MGR
                anonymous=anonymous,
            </value>
        </property>
    </bean>

   <!-- define, but don't use until you're ready to attach to a non-inMemoryDao -->
   <bean id="passwordEncoder" class="org.acegisecurity.providers.encoding.Md5PasswordEncoder"/>

   <bean id="daoAuthenticationProvider" class="org.acegisecurity.providers.dao.DaoAuthenticationProvider">
      <property name="userDetailsService"><ref local="inMemoryDaoImpl"/></property>

   </bean>
   <!-- InMemoryDao doesn't encode passwords...it's gotta be plaintext -->
   <!--       <property name="passwordEncoder"><ref local="passwordEncoder"/></property> -->


   <!-- Automatically receives AuthenticationEvent messages -->
   <bean id="loggerListener" class="org.acegisecurity.event.authentication.LoggerListener"/>

   <bean id="basicProcessingFilter" class="org.acegisecurity.ui.basicauth.BasicProcessingFilter">
      <property name="authenticationManager"><ref local="authenticationManager"/></property>
      <property name="authenticationEntryPoint"><ref local="basicProcessingFilterEntryPoint"/></property>
   </bean>

   <!-- Essentially Unused unless you're using Basic Authentication, which we're not -->
   <bean id="basicProcessingFilterEntryPoint" class="org.acegisecurity.ui.basicauth.BasicProcessingFilterEntryPoint">
      <property name="realmName"><value>Contacts Realm</value></property>
   </bean>

   <bean id="anonymousProcessingFilter" class="org.acegisecurity.providers.anonymous.AnonymousProcessingFilter">
      <property name="key"><value>foobar</value></property>
      <property name="userAttribute"><value>anonymousUser,ROLE_ANONYMOUS</value></property>
   </bean>

   <bean id="anonymousAuthenticationProvider" class="org.acegisecurity.providers.anonymous.AnonymousAuthenticationProvider">
      <property name="key"><value>foobar</value></property>
   </bean>

   <bean id="httpSessionContextIntegrationFilter" class="org.acegisecurity.context.HttpSessionContextIntegrationFilter">
   </bean>

   <!-- ===================== HTTP REQUEST SECURITY ==================== -->

   <bean id="exceptionTranslationFilter" class="org.acegisecurity.ui.ExceptionTranslationFilter">
      <property name="authenticationEntryPoint"><ref local="authenticationProcessingFilterEntryPoint"/></property>
   </bean>

   <bean id="authenticationProcessingFilter" class="org.acegisecurity.ui.webapp.AuthenticationProcessingFilter">
      <property name="authenticationManager"><ref bean="authenticationManager"/></property>
      <property name="authenticationFailureUrl"><value>/LoginFailed.html</value></property>
      <property name="defaultTargetUrl"><value>/Home.html</value></property>
      <property name="filterProcessesUrl"><value>/j_acegi_security_check</value></property>
   </bean>

   <bean id="authenticationProcessingFilterEntryPoint"

class="org.acegisecurity.ui.webapp.AuthenticationProcessingFilterEntryPoint">
      <property name="loginFormUrl"><value>/Login.html</value></property>
      <property name="forceHttps"><value>false</value></property>
   </bean>

   <bean id="httpRequestAccessDecisionManager" class="org.acegisecurity.vote.AffirmativeBased">
      <property name="allowIfAllAbstainDecisions"><value>false</value></property>
      <property name="decisionVoters">
         <list>
            <ref bean="roleVoter"/>
         </list>
      </property>
   </bean>

   <!-- An access decision voter that reads ROLE_* configuration settings -->
   <bean id="roleVoter" class="org.acegisecurity.vote.RoleVoter"/>  
  

   <!-- Note the order that entries are placed against the objectDefinitionSource is critical.
        The FilterSecurityInterceptor will work from the top of the list down to the FIRST pattern that matches the request URL.
        Accordingly, you should place MOST SPECIFIC (ie a/b/c/d.*) expressions first, with LEAST SPECIFIC (ie a/.*) expressions last -->     
                                    
   <bean id="filterInvocationInterceptor" class="org.acegisecurity.intercept.web.FilterSecurityInterceptor">
      <property name="authenticationManager"><ref bean="authenticationManager"/></property>
      <property name="accessDecisionManager"><ref local="httpRequestAccessDecisionManager"/></property>
      <property name="objectDefinitionSource">
         <value>                            
                 PATTERN_TYPE_APACHE_ANT
                 /media/*=ROLE_ANONYMOUS,ROLE_USER
                 /styles/*=ROLE_ANONYMOUS,ROLE_USER
                 /AccessDenied.html*=ROLE_ANONYMOUS,ROLE_USER
                 /Login.html*=ROLE_ANONYMOUS,ROLE_USER
                 /Logout.html*=ROLE_ANONYMOUS,ROLE_USER
                 /Login,loginForm.sdirect*=ROLE_ANONYMOUS,ROLE_USER
                 /LoginFailed.html*=ROLE_ANONYMOUS,ROLE_USER
                 /Home.html*=ROLE_ANONYMOUS,ROLE_USER
                 /asset.svc*=ROLE_ANONYMOUS,ROLE_USER
                 /ManageUsers.html*=ROLE_SYSTEM_ADMIN,ROLE_USER_ADMIN
                 /**=ROLE_USER
         </value>
      </property>
   </bean>
</beans>

Some notes about this configuration:
1) If you don't create a generic "ROLE_USER" to define someone who isn't anonymous, then you need to add every single role to every single pattern in your filterInvocationInterceptor. That's a pain, so I just have every user defined to be a member of ROLE_USER in addition to their "real" role (e.g. ROLE_SYSTEM_ADMIN).

2) I can't emphasize the passwordEncoder gotchya enough...I lost a whole day trying to figure out why I kept getting redirected to the login page after I swear I correctly logged in. For the purposes of getting up and running, it's easy to use the inMemoryDaoImpl, but just be sure to comment out the use of the PasswordEncoder that you may have cut & pasted from demo code you'll find on this board and others.

Step 3) Create a Login page

If you're using a branding template (i.e. Border component), it's a good idea to comment it out as you're getting set up because Acegi will log the hell out of attempts to access images, javascript, stylesheets, etc.

Step A: Add the HTML page to your WEB-INF

<html jwcid="$content$">
<!-- body jwcid="@branding:BaseBorder" -->
<body>
<h4>EDIS3 Login</h4>

<p class="errorMessage"><span jwcid="errorMsg"/></p>
<p>Please login:</p>

    <form jwcid="loginForm">
      <table border="0">
        <tr><td>Username:</td><td><input type="text" jwcid="username"/></td></tr>
        <tr><td>Password:</td><td><input type="password" jwcid="password"/></td></tr>
        <tr><td>&nbsp;</td><td><input type="submit" value="Login"/></td></tr>
      </table>
    </form>
<pre>
&lt;property name="userMap"&gt;
  &lt;value&gt;
    tom=tvaughan,ROLE_USER,ROLE_SYSTEM_ADMIN
    sue=stillery,ROLE_USER,ROLE_SYSTEM_ADMIN               
    carlos=cfernandez,ROLE_USER,ROLE_USER_ADMIN
    joel=jmoeller,ROLE_USER,ROLE_USER_ADMIN
    tony=tgiaccone,ROLE_USER,ROLE_SERVICE_LIST_ADMIN
    jack=jrodriguez,ROLE_USER,ROLE_INVESTIGATION_ADMIN
    walter=wkelly,ROLE_USER,ROLE_INVESTIGATION_MGR
    anonymous=anonymous,
  &lt;/value&gt;
&lt;/property&gt;   
</pre>
   
<style type="text/css">
.security_debugging {
  background-color: silver;
  margin: 1em;
  padding: 1em;
  border: 1px dashed black;
}
</style>
<div class="security_debugging">
<p>Current Authentication Information:<br/>
User: <span jwcid="user"><strong>user</strong></span><br/>
Details: <span jwcid="details" raw="true">details</span><br/>
</p>
</div>
   
</body>
</html>

Step B: Define the Login.page file

Code:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE page-specification PUBLIC
  "-//Apache Software Foundation//Tapestry Specification 4.0//EN"
  "http://jakarta.apache.org/tapestry/dtd/Tapestry_4_0.dtd">

<page-specification class="gov.usitc.edis.pages.Login">

    <description>EDIS Login</description>

    <meta key="page-title" value="EDIS Login"/>
   
     <component id="loginForm" type="Form">
       <binding name="listener" value="listener:login"/>
     </component>
     <component id="username" type="TextField">
       <binding name="value" value="username"/>
     </component>
    <component id="password" type="TextField">
       <binding name="value" value="password"/>
       <binding name="hidden" value="true"/>
     </component>    
     <component id="errorMsg" type="Delegator">
       <binding name="delegate" value="beans.delegate.firstError"/>
     </component>
     <component id="user" type="Insert">
         <binding name="value" value="user"/>
     </component>
     <component id="details" type="Insert">
         <binding name="value" value="details"/>
     </component>     
</page-specification>

Step C: Add the Login.java file to your source tree

package gov.usitc.edis.pages;

import org.acegisecurity.Authentication;
import org.acegisecurity.GrantedAuthority;
import org.acegisecurity.context.SecurityContextHolder;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.tapestry.IRequestCycle;
import org.apache.tapestry.RedirectException;
import org.apache.tapestry.html.BasePage;
import org.apache.tapestry.valid.ValidationDelegate;

import org.apache.tapestry.annotations.Bean;
import org.apache.tapestry.annotations.InjectObject;
import org.apache.tapestry.event.PageBeginRenderListener;
import org.apache.tapestry.event.PageEvent;



public abstract class Login extends BasePage implements PageBeginRenderListener {
   
    @SuppressWarnings("unused")
    private final static Log LOG = LogFactory.getLog(Login.class);   
      
    private String username;
    private String password;
   
    @Bean
    public abstract ValidationDelegate getDelegate();
   
    public void login(IRequestCycle cycle) throws RedirectException {
        String acegiUrl = cycle.getAbsoluteURL("/j_acegi_security_check?j_username="+getUsername()+"&j_password="+getPassword());
        LOG.info("Throwing redirect exception to '" + acegiUrl + "'");
        throw new RedirectException(acegiUrl);       
    }   
   
    public void pageBeginRender(PageEvent event){

    }
   
    public String getUser() {
        Authentication myAuth = SecurityContextHolder.getContext().getAuthentication();
        return myAuth.getName();
    }
   
    public String getDetails() {
        Authentication myAuth = SecurityContextHolder.getContext().getAuthentication();
        if(myAuth == null) {
            return "Authorization object is null";
        } else {
            StringBuffer b = new StringBuffer();
            b.append("<ul>");
            b.append("  <li>principal = " + myAuth.getPrincipal() + "</li>");
            b.append("  <li>credentials = " + myAuth.getCredentials() + "</li>");
            b.append("  <li>isAuthenticated = " + myAuth.isAuthenticated() + "</li>");
            b.append("  <li>Granted Authorities = ");
            GrantedAuthority[] gas = myAuth.getAuthorities();
            for(int i=0; i<gas.length; i++) {
                b.append(gas[i] + " ");
            }
            b.append("</li>");
            b.append("  <li>Details = " + myAuth.getDetails() + "</li>");
            b.append("  <li>Class = " + myAuth.getClass() + "</li>");           
            b.append("</ul>");
            //return myAuth.toString();
           
            return b.toString();
           }
    }
  


    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }  
}

Step 4) Add additional support pages

In the Acegi configuration context file from Step #2, you'll find references to "LoginFailed.html" and "Logout.html", and in the web.xml, there's a reference to "AccessDenied.html".

Using the same basic code for the Login page, flush out the other pages.

Because I'm still in the development phase of this project, I find it pretty helpful to put some SecureContext/Authentication display at the bottom of these pages so I know what the current authentication looks like and so I can verify expected behavior.

Step 5) Testing, Logging

I was playing around with trying to get some meaningful JUnit tests on my application working yesterday and wasn't having much success...it's pretty complicated to spool up a simple test, and even then I'm not sure unit testing is what I should be doing--- this seems more like in the realm of functional or possibly integration testing using tools other than JUnit. Stay tuned.

I found that I learned a lot about the Acegi flow of control by turning up debugging...just add this to your log4j.properties files and follow along; just be sure to remove your branding assets from your page first.

log4j.logger.org.acegisecurity=DEBUG

---------------------------------------------------------------------------------------------------------------------------------------

Tapestry is completely oblivious to the presence of Acegi...Acegi operates at the URL request level, before Tapestry even tries to respond to a request.

If you keep getting the login page with your credentials showing up as "Anonymous", make sure you aren't using a PasswordEncoder if you have an InMemoryDaoImpl as your AuthenticationProvider...

In the application I'm working on right now, we have our own business object named "EdisUser" that has the username, address, phone number, etc. in it that is completely independent of Acegi. We use Acegi to challenge you at the login page. Assuming you login correctly, we then go an grab your EdisUser object out of the DB and "do stuff" with that POJO. If we need to check your permission to do something, we can grab your logged-in/valid authentication object from Acegi's SecurityContext, but it's rare that we need to do that.

The only real touch point between Tapestry and Acegi in the login use case is if you use Tapestry to handle the form component on the Login.html page. In my situation, my username field is called "username" and my password field is called "password." Those fields are components defined in the Login.page file which uses the Login.java object as a backing POJO.

My form submit in Login.java looks like this:

public void login(IRequestCycle cycle) throws RedirectException {
         String ciphertext = getCipherText(getPassword());
       
        String acegiUrl = cycle.getAbsoluteURL(
                "/j_acegi_security_check?j_username=" +
                getUsername() +
                "&j_password=" +
                ciphertext);
        LOG.info("Throwing redirect exception to '" + acegiUrl + "'");
       
            throw new RedirectException(acegiUrl);       
}

So you see that Tapestry basically assembles a servlet-style URL and throws a redirect exception to that url. In the web.xml, that /j_acegi_security_check is listened for and winds up in the authenticationProcessingFilter defined in my application-context.xml file.

Tapestry is aware of the username and password because Tapestry is responsible for rendering and processing the login form. So on the Login.html page, you'll see code like this:

<form jwcid="loginForm">
      <table border="0">
        <tr><td>Username:</td><td><input type="text" jwcid="username"/></td></tr>
        <tr><td>Password:</td><td><input type="password" jwcid="password"/></td></tr>
        <tr><td>&nbsp;</td><td><input type="submit" value="Login"/></td></tr>
      </table>
    </form>

Notice that the jwcid of the fields are not j_username and j_password.

When a user fills in those fields and posts the form, tapestry routes the strings down to the Login.java class for processing. In my case, it's the "login" method that handles that form posting.

In the login() method, I finesse the username and password strings** and then throw a redirect exception to the servlet that Acegi is listening to:

 String acegiUrl = cycle.getAbsoluteURL(
        "/j_acegi_security_check?j_username=" +
        getUsername() +
        "&j_password=" +
        ciphertext);
    LOG.info("Throwing redirect exception to '" + acegiUrl + "'");
       
    throw new RedirectException(acegiUrl);

Note that in the 'acegiUrl' I am using the j_username and j_password parameter names.

Ok, so Acegi sees that post to the j_acegi_security_check servlet (mapped in the web.xml file, remember) and goes and does its thing. Assuming the login was valid, it then redirects the user to whatever your 'defaultTargetUrl' value is. Mine is configured like this:

<bean id="authenticationProcessingFilter" class="org.acegisecurity.ui.webapp.AuthenticationProcessingFilter">
      <property name="authenticationManager"><ref bean="authenticationManager"/></property>
      <property name="authenticationFailureUrl"><value>/LoginFailed.html</value></property>
      <property name="defaultTargetUrl"><value>/Home.html</value></property>
      <property name="filterProcessesUrl"><value>/j_acegi_security_check</value></property>
   </bean>

So now the user is looking at the Home.html page, rendered courtesy of Tapestry. That page was backed by the Home.java class. If the Home.java class needs to get that user's info, it can make a call like this (for example):

public void pageBeginRender(PageEvent event){   
        Authentication myAuth =
             SecurityContextHolder.getContext().getAuthentication();
        if(myAuth == null) {
            LOG.info("Authorization object is null");
        } else {
            String name = myAuth.getName();
            EdisUser currentUser = getUserByName(name);
            // now do stuff with my EdisUser business object
        }
    }

Hope this helps,
Tom

**= in my situation, I encrypt the password with the Md5Encoder before sending it to the j_acegi_security_check servlet. Check out hispacta.blogspot.com if you're curious as to why.