diff --git a/.gitignore b/.gitignore index b19ec3cf87..2285f14a44 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ target/ .project .DS_Store .settings/ +out/ build/ *.log *.iml @@ -11,3 +12,4 @@ build/ *.iws .gradle/ gradle.properties +atlassian-ide-plugin.xml diff --git a/build.gradle b/build.gradle index d7489a14da..1b2b014c9b 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,9 @@ allprojects { if (!config) { return } + ideaModule { + gradleCacheVariable = 'GRADLE_CACHE' + } } ideaModule { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index c0cdf8c470..51c5a3999b 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -3,9 +3,11 @@ apply plugin: 'groovy' repositories { mavenRepo name:'localRepo', urls: "file://" + System.properties['user.home'] + "/.m2/repository" mavenCentral() + mavenRepo name: 'GAE', urls:'http://maven-gae-plugin.googlecode.com/svn/repository' mavenRepo name:'Shibboleth Repo', urls:'http://shibboleth.internet2.edu/downloads/maven2' } +// Docbook Plugin dependencies { def fopDeps = [ 'org.apache.xmlgraphics:fop:0.95-1@jar', 'org.apache.xmlgraphics:xmlgraphics-commons:1.3', @@ -26,6 +28,11 @@ dependencies { 'net.sf.docbook:docbook-xsl:1.75.2:ns-resources@zip' } +// GAE +dependencies { + compile 'com.google.appengine:appengine-tools-api:1.3.5' +} + task ide(type: Copy) { from configurations.runtime into 'ide' diff --git a/buildSrc/src/main/groovy/gae/GaePlugin.groovy b/buildSrc/src/main/groovy/gae/GaePlugin.groovy new file mode 100644 index 0000000000..0c22dc0931 --- /dev/null +++ b/buildSrc/src/main/groovy/gae/GaePlugin.groovy @@ -0,0 +1,26 @@ +package gae; + +import com.google.appengine.tools.admin.AppCfg +import org.gradle.api.*; + +class GaePlugin implements Plugin { + public void apply(Project project) { + if (!project.hasProperty('appEngineSdkRoot')) { + println "'appEngineSdkRoot' must be set in gradle.properties" + } + + System.setProperty('appengine.sdk.root', project.property('appEngineSdkRoot')) + + File explodedWar = new File(project.buildDir, "gae-exploded") + + project.task('gaeDeploy') << { + AppCfg.main("update", explodedWar.toString()) + } + + project.gaeDeploy.dependsOn project.war + + project.war.doLast { + ant.unzip(src: project.war.archivePath, dest: explodedWar) + } + } +} diff --git a/buildSrc/src/main/resources/META-INF/gradle-plugins/gae.properties b/buildSrc/src/main/resources/META-INF/gradle-plugins/gae.properties new file mode 100644 index 0000000000..d6c44963b6 --- /dev/null +++ b/buildSrc/src/main/resources/META-INF/gradle-plugins/gae.properties @@ -0,0 +1 @@ +implementation-class=gae.GaePlugin \ No newline at end of file diff --git a/samples/gae/gae.gradle b/samples/gae/gae.gradle new file mode 100644 index 0000000000..6c5bf73a48 --- /dev/null +++ b/samples/gae/gae.gradle @@ -0,0 +1,40 @@ +apply plugin: 'war' +apply plugin: 'jetty' +apply plugin: 'gae' + +gaeVersion="1.3.5" + +repositories { + // Hibernate Validator + mavenRepo name: 'JBoss', urls: 'https://repository.jboss.org/nexus/content/repositories/releases' + // GAE Jars + mavenRepo name: 'GAE', urls:'http://maven-gae-plugin.googlecode.com/svn/repository' +} + +// Remove logback as it causes security issues with GAE. +configurations.runtime.exclude(group: 'ch.qos.logback') + +dependencies { + providedCompile 'javax.servlet:servlet-api:2.5@jar', + "com.google.appengine:appengine-api-1.0-sdk:$gaeVersion" + + compile project(':spring-security-core'), + project(':spring-security-web'), + "org.springframework:spring-beans:$springVersion", + "org.springframework:spring-web:$springVersion", + "org.springframework:spring-webmvc:$springVersion", + "org.springframework:spring-context:$springVersion", + "org.springframework:spring-context-support:$springVersion", + 'javax.validation:validation-api:1.0.0.GA', + 'org.hibernate:hibernate-validator:4.1.0.Final', + "org.slf4j:slf4j-api:$slf4jVersion" + + runtime project(':spring-security-config'), + "org.slf4j:jcl-over-slf4j:$slf4jVersion", + "org.slf4j:slf4j-jdk14:$slf4jVersion" + testCompile "com.google.appengine:appengine-testing:$gaeVersion" + + testRuntime "com.google.appengine:appengine-api-labs:$gaeVersion", + "com.google.appengine:appengine-api-stubs:$gaeVersion" + +} diff --git a/samples/gae/src/main/java/samples/gae/security/AppRole.java b/samples/gae/src/main/java/samples/gae/security/AppRole.java new file mode 100644 index 0000000000..f59a75e96e --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/security/AppRole.java @@ -0,0 +1,33 @@ +package samples.gae.security; + +import org.springframework.security.core.GrantedAuthority; + +/** + * @author Luke Taylor + */ +public enum AppRole implements GrantedAuthority { + ADMIN (0), + NEW_USER (1), + USER (2); + + private int bit; + + /** + * Creates an authority with a specific bit representation. It's important that this doesn't + * change as it will be used in the database. The enum ordinal is less reliable as the enum may be + * reordered or have new roles inserted which would change the ordinal values. + * + * @param bit the permission bit which will represent this authority in the datastore. + */ + AppRole(int bit) { + this.bit = bit; + } + + public int getBit() { + return bit; + } + + public String getAuthority() { + return toString(); + } +} diff --git a/samples/gae/src/main/java/samples/gae/security/GaeAuthenticationFilter.java b/samples/gae/src/main/java/samples/gae/security/GaeAuthenticationFilter.java new file mode 100644 index 0000000000..c1960c5026 --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/security/GaeAuthenticationFilter.java @@ -0,0 +1,88 @@ +package samples.gae.security; + +import java.io.IOException; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.appengine.api.users.User; +import com.google.appengine.api.users.UserServiceFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.AuthenticationDetailsSource; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import org.springframework.util.Assert; +import org.springframework.web.filter.GenericFilterBean; + +/** + * @author Luke Taylor + */ +public class GaeAuthenticationFilter extends GenericFilterBean { + private static final String REGISTRATION_URL = "/register.htm"; + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + 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 googleUser = UserServiceFactory.getUserService().getCurrentUser(); + + if (googleUser != null) { + logger.debug("Currently logged on to GAE as user " + googleUser); + logger.debug("Authenticating to Spring Security"); + // User has returned after authenticating via GAE. Need to authenticate through Spring Security. + PreAuthenticatedAuthenticationToken token = new PreAuthenticatedAuthenticationToken(googleUser, null); + token.setDetails(ads.buildDetails(request)); + + try { + authentication = authenticationManager.authenticate(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (AuthenticationException e) { + failureHandler.onAuthenticationFailure((HttpServletRequest)request, (HttpServletResponse)response, e); + + return; + } + } + } + + // A new user has to register with the app before doing anything else + if (authentication != null && authentication.getAuthorities().contains(AppRole.NEW_USER) + && !((HttpServletRequest)request).getRequestURI().endsWith(REGISTRATION_URL)) { + logger.debug("New user authenticated. Redirecting to registration page"); + + ((HttpServletResponse) response).sendRedirect(REGISTRATION_URL); + + return; + } + + chain.doFilter(request, response); + } + + @Override + public void afterPropertiesSet() throws ServletException { + Assert.notNull(authenticationManager, "AuthenticationManager must be set"); + } + + public void setAuthenticationManager(AuthenticationManager authenticationManager) { + this.authenticationManager = authenticationManager; + } + + public void setFailureHandler(AuthenticationFailureHandler failureHandler) { + this.failureHandler = failureHandler; + } +} diff --git a/samples/gae/src/main/java/samples/gae/security/GaeUserAuthentication.java b/samples/gae/src/main/java/samples/gae/security/GaeUserAuthentication.java new file mode 100644 index 0000000000..619ef6a6dd --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/security/GaeUserAuthentication.java @@ -0,0 +1,62 @@ +package samples.gae.security; + +import java.util.Collection; +import java.util.HashSet; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import samples.gae.users.GaeUser; + +/** + * Authentication object representing a fully-authenticated user. + * + * @author Luke Taylor + */ +public class GaeUserAuthentication implements Authentication { + private final GaeUser principal; + private final Object details; + private boolean authenticated; + + public GaeUserAuthentication(GaeUser principal, Object details) { + this.principal = principal; + this.details = details; + authenticated = true; + } + + public Collection getAuthorities() { + return new HashSet(principal.getAuthorities()); + } + + public Object getCredentials() { + throw new UnsupportedOperationException(); + } + + public Object getDetails() { + return null; + } + + public Object getPrincipal() { + return principal; + } + + public boolean isAuthenticated() { + return authenticated; + } + + public void setAuthenticated(boolean isAuthenticated) { + authenticated = isAuthenticated; + } + + public String getName() { + return principal.getUserId(); + } + + @Override + public String toString() { + return "GaeUserAuthentication{" + + "principal=" + principal + + ", details=" + details + + ", authenticated=" + authenticated + + '}'; + } +} diff --git a/samples/gae/src/main/java/samples/gae/security/GoogleAccountsAuthenticationEntryPoint.java b/samples/gae/src/main/java/samples/gae/security/GoogleAccountsAuthenticationEntryPoint.java new file mode 100644 index 0000000000..3bce1c376d --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/security/GoogleAccountsAuthenticationEntryPoint.java @@ -0,0 +1,23 @@ +package samples.gae.security; + +import java.io.IOException; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.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())); + } +} diff --git a/samples/gae/src/main/java/samples/gae/security/GoogleAccountsAuthenticationProvider.java b/samples/gae/src/main/java/samples/gae/security/GoogleAccountsAuthenticationProvider.java new file mode 100644 index 0000000000..e286f66694 --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/security/GoogleAccountsAuthenticationProvider.java @@ -0,0 +1,64 @@ +package samples.gae.security; + +import com.google.appengine.api.users.User; +import org.springframework.context.MessageSource; +import org.springframework.context.MessageSourceAware; +import org.springframework.context.support.MessageSourceAccessor; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.SpringSecurityMessageSource; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +import samples.gae.users.GaeUser; +import samples.gae.users.UserRegistry; + +/** + * A simple authentication provider which interacts with {@code User} returned by the GAE {@code UserService}, + * and also the local persistent {@code UserRegistry} to build an application user principal. + *

+ * If the user has been authenticated through google accounts, it will check if they are already registered + * and either load the existing user information or assign them a temporary identity with limited access until they + * have registered. + *

+ * If the account has been disabled, a {@code DisabledException} will be raised. + * + * @author Luke Taylor + */ +public class GoogleAccountsAuthenticationProvider implements AuthenticationProvider, MessageSourceAware { + protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); + + 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()); + } + + /** + * Indicate that this provider only supports PreAuthenticatedAuthenticationToken (sub)classes. + */ + public final boolean supports(Class authentication) { + return PreAuthenticatedAuthenticationToken.class.isAssignableFrom(authentication); + } + + public void setUserRegistry(UserRegistry userRegistry) { + this.userRegistry = userRegistry; + } + + public void setMessageSource(MessageSource messageSource) { + this.messages = new MessageSourceAccessor(messageSource); + } +} diff --git a/samples/gae/src/main/java/samples/gae/users/GaeDataStoreUserRegistry.java b/samples/gae/src/main/java/samples/gae/users/GaeDataStoreUserRegistry.java new file mode 100644 index 0000000000..6fd1ff2f5c --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/users/GaeDataStoreUserRegistry.java @@ -0,0 +1,93 @@ +package samples.gae.users; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.Set; + +import com.google.appengine.api.datastore.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import samples.gae.security.AppRole; + +/** + * UserRegistry implementation which uses GAE's low-level Datastore APIs. + * + * @author Luke Taylor + */ +public class GaeDataStoreUserRegistry implements UserRegistry { + private final Logger logger = LoggerFactory.getLogger(getClass()); + + 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 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) { + logger.debug("Attempting to create new user " + 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 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); + } +} diff --git a/samples/gae/src/main/java/samples/gae/users/GaeUser.java b/samples/gae/src/main/java/samples/gae/users/GaeUser.java new file mode 100644 index 0000000000..6e23fb6546 --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/users/GaeUser.java @@ -0,0 +1,92 @@ +package samples.gae.users; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.Set; + +import org.springframework.security.core.GrantedAuthority; +import samples.gae.security.AppRole; + +/** + * Custom user object for the application. + * + * @author Luke Taylor + */ +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 authorities; + private final boolean enabled; + + /** + * Pre-registration constructor. + * + * Assigns the user the "NEW_USER" role only. + */ + public GaeUser(String userId, String nickname, String email) { + this.userId = userId; + this.nickname = nickname; + this.authorities = EnumSet.of(AppRole.NEW_USER); + this.forename = null; + this.surname = null; + this.email = email; + this.enabled = true; + } + + /** + * Post-registration constructor + */ + public GaeUser(String userId, String nickname, String email, String forename, String surname, Set authorities, boolean enabled) { + this.userId = userId; + this.nickname = nickname; + this.email = email; + this.authorities = authorities; + this.forename = forename; + this.surname = surname; + this.enabled= enabled; + } + + public String getUserId() { + return userId; + } + + public String getNickname() { + return nickname; + } + + public String getEmail() { + return email; + } + + public String getForename() { + return forename; + } + + public String getSurname() { + return surname; + } + + public boolean isEnabled() { + return enabled; + } + + public Collection getAuthorities() { + return authorities; + } + + @Override + public String toString() { + return "GaeUser{" + + "userId='" + userId + '\'' + + ", nickname='" + nickname + '\'' + + ", forename='" + forename + '\'' + + ", surname='" + surname + '\'' + + ", authorities=" + authorities + + '}'; + } +} diff --git a/samples/gae/src/main/java/samples/gae/users/InMemoryUserRegistry.java b/samples/gae/src/main/java/samples/gae/users/InMemoryUserRegistry.java new file mode 100644 index 0000000000..16be6aabef --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/users/InMemoryUserRegistry.java @@ -0,0 +1,34 @@ +package samples.gae.users; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.Assert; + +/** + * @author Luke Taylor + */ +public class InMemoryUserRegistry implements UserRegistry { + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private Map users = Collections.synchronizedMap(new HashMap()); + + public GaeUser findUser(String userId) { + return users.get(userId); + } + + public void registerUser(GaeUser newUser) { + logger.debug("Attempting to create new user " + newUser); + + Assert.state(!users.containsKey(newUser.getUserId())); + + users.put(newUser.getUserId(), newUser); + } + + public void removeUser(String userId) { + users.remove(userId); + } +} diff --git a/samples/gae/src/main/java/samples/gae/users/UserRegistry.java b/samples/gae/src/main/java/samples/gae/users/UserRegistry.java new file mode 100644 index 0000000000..1b5bbe8bab --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/users/UserRegistry.java @@ -0,0 +1,18 @@ +package samples.gae.users; + +import com.google.appengine.api.datastore.EntityNotFoundException; + +/** + * + * Service used to maintain a list of users who are registered with the application. + * + * @author Luke Taylor + */ +public interface UserRegistry { + + GaeUser findUser(String userId); + + void registerUser(GaeUser newUser); + + void removeUser(String userId); +} diff --git a/samples/gae/src/main/java/samples/gae/validation/Forename.java b/samples/gae/src/main/java/samples/gae/validation/Forename.java new file mode 100644 index 0000000000..1f0c52eebf --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/validation/Forename.java @@ -0,0 +1,25 @@ +package samples.gae.validation; + +import static java.lang.annotation.ElementType.*; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.validation.Constraint; +import javax.validation.Payload; + +import org.hibernate.validator.constraints.NotBlank; + +/** + * @author Luke Taylor + */ +@Target( { METHOD, FIELD, ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ForenameValidator.class) +public @interface Forename { + String message() default "{samples.gae.forename}"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/samples/gae/src/main/java/samples/gae/validation/ForenameValidator.java b/samples/gae/src/main/java/samples/gae/validation/ForenameValidator.java new file mode 100644 index 0000000000..bbe9e39ed2 --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/validation/ForenameValidator.java @@ -0,0 +1,18 @@ +package samples.gae.validation; + +import java.util.regex.Pattern; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +/** + * @author Luke Taylor + */ +public class ForenameValidator implements ConstraintValidator { + private static final Pattern VALID = Pattern.compile("[\\p{L}'\\-,.]+") ; + + public void initialize(Forename constraintAnnotation) {} + + public boolean isValid(String value, ConstraintValidatorContext context) { + return VALID.matcher(value).matches(); + } +} diff --git a/samples/gae/src/main/java/samples/gae/validation/Surname.java b/samples/gae/src/main/java/samples/gae/validation/Surname.java new file mode 100644 index 0000000000..c480630dcf --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/validation/Surname.java @@ -0,0 +1,25 @@ +package samples.gae.validation; + +import static java.lang.annotation.ElementType.*; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import javax.validation.Constraint; +import javax.validation.Payload; + +import org.hibernate.validator.constraints.NotBlank; + +/** + * @author Luke Taylor + */ +@Target( { METHOD, FIELD, ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = SurnameValidator.class) +public @interface Surname { + String message() default "{samples.gae.surname}"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/samples/gae/src/main/java/samples/gae/validation/SurnameValidator.java b/samples/gae/src/main/java/samples/gae/validation/SurnameValidator.java new file mode 100644 index 0000000000..a6e2bdaa5b --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/validation/SurnameValidator.java @@ -0,0 +1,18 @@ +package samples.gae.validation; + +import java.util.regex.Pattern; +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +/** + * @author Luke Taylor + */ +public class SurnameValidator implements ConstraintValidator { + private static final Pattern VALID = Pattern.compile("[\\p{L}'\\-,.]+") ; + + public void initialize(Surname constraintAnnotation) {} + + public boolean isValid(String value, ConstraintValidatorContext context) { + return VALID.matcher(value).matches(); + } +} diff --git a/samples/gae/src/main/java/samples/gae/web/GaeAppController.java b/samples/gae/src/main/java/samples/gae/web/GaeAppController.java new file mode 100644 index 0000000000..820ef99e25 --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/web/GaeAppController.java @@ -0,0 +1,48 @@ +package samples.gae.web; + +import java.io.IOException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import com.google.appengine.api.users.UserServiceFactory; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +/** + * + * @author Luke Taylor + * + */ +@Controller +public class GaeAppController { + + @RequestMapping(value = "/", method= RequestMethod.GET) + public String landing() { + return "landing"; + } + + @RequestMapping(value = "/home.htm", method= RequestMethod.GET) + public String home() { + return "home"; + } + + @RequestMapping(value = "/disabled.htm", method= RequestMethod.GET) + public String disabled() { + return "disabled"; + } + + @RequestMapping(value = "/logout.htm", method= RequestMethod.GET) + public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException { + request.getSession().invalidate(); + + String logoutUrl = UserServiceFactory.getUserService().createLogoutURL("/"); + + response.sendRedirect(logoutUrl); + } + + @RequestMapping(value = "/loggedout.htm", method= RequestMethod.GET) + public String loggedOut() { + return "loggedout"; + } +} diff --git a/samples/gae/src/main/java/samples/gae/web/RegistrationController.java b/samples/gae/src/main/java/samples/gae/web/RegistrationController.java new file mode 100644 index 0000000000..d9a169c903 --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/web/RegistrationController.java @@ -0,0 +1,61 @@ +package samples.gae.web; + +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.Set; +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; + +import com.google.appengine.api.users.UserServiceFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Controller; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import samples.gae.security.AppRole; +import samples.gae.security.GaeUserAuthentication; +import samples.gae.users.GaeUser; +import samples.gae.users.UserRegistry; + +/** + * @author Luke Taylor + */ +@Controller +@RequestMapping(value="/register.htm") +public class RegistrationController { + + @Autowired + private UserRegistry registry; + + @RequestMapping(method= RequestMethod.GET) + public RegistrationForm registrationForm() { + return new RegistrationForm(); + } + + @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 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"; + } +} diff --git a/samples/gae/src/main/java/samples/gae/web/RegistrationForm.java b/samples/gae/src/main/java/samples/gae/web/RegistrationForm.java new file mode 100644 index 0000000000..f9be66d716 --- /dev/null +++ b/samples/gae/src/main/java/samples/gae/web/RegistrationForm.java @@ -0,0 +1,31 @@ +package samples.gae.web; + +import org.hibernate.validator.constraints.NotBlank; +import samples.gae.validation.Forename; +import samples.gae.validation.Surname; + +/** + * @author Luke Taylor + */ +public class RegistrationForm { + @Forename + private String forename; + @Surname + private String surname; + + public String getForename() { + return forename; + } + + public void setForename(String forename) { + this.forename = forename; + } + + public String getSurname() { + return surname; + } + + public void setSurname(String surname) { + this.surname = surname; + } +} diff --git a/samples/gae/src/main/webapp/WEB-INF/appengine-web.xml b/samples/gae/src/main/webapp/WEB-INF/appengine-web.xml new file mode 100644 index 0000000000..3a42c3f899 --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/appengine-web.xml @@ -0,0 +1,10 @@ + + + gaespringsec + 1 + true + + + + + diff --git a/samples/gae/src/main/webapp/WEB-INF/applicationContext-security.xml b/samples/gae/src/main/webapp/WEB-INF/applicationContext-security.xml new file mode 100644 index 0000000000..f4b8f799e1 --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/applicationContext-security.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/gae/src/main/webapp/WEB-INF/classes/ValidationMessages.properties b/samples/gae/src/main/webapp/WEB-INF/classes/ValidationMessages.properties new file mode 100644 index 0000000000..d728550595 --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/classes/ValidationMessages.properties @@ -0,0 +1,2 @@ +samples.gae.forename=Must be a valid forename +samples.gae.surname=Must be a valid surname diff --git a/samples/gae/src/main/webapp/WEB-INF/gae-servlet.xml b/samples/gae/src/main/webapp/WEB-INF/gae-servlet.xml new file mode 100644 index 0000000000..6835929075 --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/gae-servlet.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/samples/gae/src/main/webapp/WEB-INF/jsp/disabled.jsp b/samples/gae/src/main/webapp/WEB-INF/jsp/disabled.jsp new file mode 100644 index 0000000000..390c0f008e --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/jsp/disabled.jsp @@ -0,0 +1,16 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + +<%@page session="false" %> + + + + + + Disabled Account + +

+

Sorry, it looks like your account has been disabled for some reason...

+
+ + + diff --git a/samples/gae/src/main/webapp/WEB-INF/jsp/home.jsp b/samples/gae/src/main/webapp/WEB-INF/jsp/home.jsp new file mode 100644 index 0000000000..eb7bf065b7 --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/jsp/home.jsp @@ -0,0 +1,26 @@ +<%@ page import="com.google.appengine.api.users.UserServiceFactory" %> +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + + + + + + + Home Page + + +
+

The Home Page

+

Welcome back .

+

+ You can get to this page if you have authenticated and are a registered user. + You are registered as + . +

+

+ Logout. +

+
+ + diff --git a/samples/gae/src/main/webapp/WEB-INF/jsp/landing.jsp b/samples/gae/src/main/webapp/WEB-INF/jsp/landing.jsp new file mode 100644 index 0000000000..f9b305203d --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/jsp/landing.jsp @@ -0,0 +1,33 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@page session="false" %> + + + + + + + Spring Security GAE Sample + + + +
+

Spring Security GAE Application

+ +

+ This application demonstrates the integration of Spring Security + with the services provided by Google App Engine. It shows how to: +

    +
  • Authenticate using Google Accounts.
  • +
  • Implement "on–demand" authentication when a user accesses a secured resource.
  • +
  • Supplement the information from Google Accounts with application–specific roles.
  • +
  • Store user account data in an App Engine datastore using the native API.
  • +
  • Setup access-control restrictions based on the roles assigned to users.
  • +
  • Disable the accounts of specfic users to prevent access.
  • +
+

+

+ Go to the home page. +

+
+ + diff --git a/samples/gae/src/main/webapp/WEB-INF/jsp/loggedout.jsp b/samples/gae/src/main/webapp/WEB-INF/jsp/loggedout.jsp new file mode 100644 index 0000000000..4bfb44537f --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/jsp/loggedout.jsp @@ -0,0 +1,17 @@ +<%@ page contentType="text/html;charset=UTF-8" language="java" %> +<%@page session="false" %> + + + + + + + Spring Security GAE Sample + + + +
+

You've been logged out of the application. Log back in.

+
+ + diff --git a/samples/gae/src/main/webapp/WEB-INF/jsp/register.jsp b/samples/gae/src/main/webapp/WEB-INF/jsp/register.jsp new file mode 100644 index 0000000000..8aaf625afe --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/jsp/register.jsp @@ -0,0 +1,40 @@ +<%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %> +<%@ taglib uri="http://www.springframework.org/tags/form" prefix="form" %> +<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> +<%@ page contentType="text/html;charset=UTF-8" language="java" %> + + + + + +Registration + + +
+

+Welcome to the Spring Security GAE sample application, . +Please enter your registration details in order to use the application. +

+

+The data you enter here will be registered in the application's GAE data store, keyed under your unique +Google Accounts identifier. It doesn't have to be accurate. When you log in again, the information will be automatically +retrieved. +

+ + +
+ + Forename: +
+
+ + + Surname: +
+
+
+ +
+ +
+ diff --git a/samples/gae/src/main/webapp/WEB-INF/logging.properties b/samples/gae/src/main/webapp/WEB-INF/logging.properties new file mode 100644 index 0000000000..daa51fde33 --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/logging.properties @@ -0,0 +1,4 @@ +.level = INFO + +org.springframework.level = FINE +org.springframework.security.level = FINER \ No newline at end of file diff --git a/samples/gae/src/main/webapp/WEB-INF/web.xml b/samples/gae/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000000..09a622be25 --- /dev/null +++ b/samples/gae/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,46 @@ + + + + + contextConfigLocation + + /WEB-INF/applicationContext-security.xml + + + + + springSecurityFilterChain + org.springframework.web.filter.DelegatingFilterProxy + + + + springSecurityFilterChain + /* + + + + org.springframework.web.context.ContextLoaderListener + + + + gae + org.springframework.web.servlet.DispatcherServlet + + contextConfigLocation + /WEB-INF/gae-servlet.xml + + + + + gae + / + + + + gae + *.htm + + + diff --git a/samples/gae/src/main/webapp/favicon.ico b/samples/gae/src/main/webapp/favicon.ico new file mode 100644 index 0000000000..54371328f6 Binary files /dev/null and b/samples/gae/src/main/webapp/favicon.ico differ diff --git a/samples/gae/src/main/webapp/static/css/gae.css b/samples/gae/src/main/webapp/static/css/gae.css new file mode 100644 index 0000000000..40051c14af --- /dev/null +++ b/samples/gae/src/main/webapp/static/css/gae.css @@ -0,0 +1,26 @@ + +body { + font-family:"Palatino Linotype","Book Antiqua",Palatino,serif; +} + +#content { + margin: 5em auto; + width: 40em; +} + +form { + width: 25em; + margin: 0 2em; +} + +form fieldset { + margin-bottom: 0.5em; +} + +fieldset input { + margin: 0.6em 0; +} + +.fieldError { + color: red; +} diff --git a/samples/gae/src/test/java/samples/gae/security/AppRoleTests.java b/samples/gae/src/test/java/samples/gae/security/AppRoleTests.java new file mode 100644 index 0000000000..3615bd7ec3 --- /dev/null +++ b/samples/gae/src/test/java/samples/gae/security/AppRoleTests.java @@ -0,0 +1,23 @@ +package samples.gae.security; + +import static org.junit.Assert.*; +import static samples.gae.security.AppRole.*; + +import org.junit.Test; +import org.springframework.security.core.GrantedAuthority; + +/** + * @author Luke Taylor + */ +public class AppRoleTests { + + @Test + public void getAuthorityReturnsRoleName() { + GrantedAuthority admin = ADMIN; + + assertEquals("ADMIN", admin.getAuthority()); + } + + + +} diff --git a/samples/gae/src/test/java/samples/gae/users/GaeDataStoreUserRegistryTests.java b/samples/gae/src/test/java/samples/gae/users/GaeDataStoreUserRegistryTests.java new file mode 100644 index 0000000000..36998072e8 --- /dev/null +++ b/samples/gae/src/test/java/samples/gae/users/GaeDataStoreUserRegistryTests.java @@ -0,0 +1,53 @@ +package samples.gae.users; + +import static org.junit.Assert.assertEquals; + +import java.util.EnumSet; +import java.util.Set; + +import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig; +import com.google.appengine.tools.development.testing.LocalServiceTestHelper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import samples.gae.security.AppRole; + +/** + * @author Luke Taylor + */ +public class GaeDataStoreUserRegistryTests { + private final LocalServiceTestHelper helper = + new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig()); + + @Before + public void setUp() throws Exception { + helper.setUp(); + } + + @After + public void tearDown() throws Exception { + helper.tearDown(); + } + + @Test + public void correctDataIsRetrievedAfterInsert() { + GaeDataStoreUserRegistry registry = new GaeDataStoreUserRegistry(); + + Set roles = EnumSet.of(AppRole.ADMIN, AppRole.USER); + String userId = "someUserId"; + + GaeUser origUser = new GaeUser(userId, "nick", "nick@blah.com", "Forename", "Surname", roles, true); + + registry.registerUser(origUser); + + GaeUser loadedUser = registry.findUser(userId); + + assertEquals(loadedUser.getUserId(), origUser.getUserId()); + assertEquals(true, loadedUser.isEnabled()); + assertEquals(roles, loadedUser.getAuthorities()); + assertEquals("nick", loadedUser.getNickname()); + assertEquals("nick@blah.com", loadedUser.getEmail()); + assertEquals("Forename", loadedUser.getForename()); + assertEquals("Surname", loadedUser.getSurname()); + } +} diff --git a/settings.gradle b/settings.gradle index 2ee654a3f5..b2c79a3c38 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,7 +14,8 @@ def String[] samples = [ 'tutorial', 'contacts', 'openid', - 'aspectj' + 'aspectj', + 'gae' ] def String[] docs = [ diff --git a/web/src/main/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilter.java b/web/src/main/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilter.java index b1a1bd1239..9807b4003b 100755 --- a/web/src/main/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilter.java +++ b/web/src/main/java/org/springframework/security/web/authentication/preauth/AbstractPreAuthenticatedProcessingFilter.java @@ -1,7 +1,6 @@ package org.springframework.security.web.authentication.preauth; import java.io.IOException; - import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; @@ -10,7 +9,6 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; -import org.springframework.beans.factory.InitializingBean; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisherAware; import org.springframework.security.authentication.AuthenticationDetailsSource; @@ -55,7 +53,7 @@ import org.springframework.web.filter.GenericFilterBean; * @since 2.0 */ public abstract class AbstractPreAuthenticatedProcessingFilter extends GenericFilterBean implements - InitializingBean, ApplicationEventPublisherAware { + ApplicationEventPublisherAware { private ApplicationEventPublisher eventPublisher = null; private AuthenticationDetailsSource authenticationDetailsSource = new WebAuthenticationDetailsSource();