April 1, 2011

Spring Security by example: OpenID (login via gmail)

This is a part of a simple Spring Security tutorial:

1. Set up and form authentication
2. User in the backend (getting logged user, authentication, testing)
3. Securing web resources
4. Securing methods
5. OpenID (login via gmail)
6. OAuth2 (login via Facebook)
7. Writing on Facebook wall with Spring Social

OpenID is to form authentication, what DVCS is to centralized version control system.

Ok, but without the technical mumbo-jumbo: OpenID allows the user to use one account (like Gmail) to login to other services (websites) without having to remember anything and without worries about password security.

Easy enough?

And the best part is: chances are, you already have an account which is a provider to OpenID. Are you registered on Gmail, Yahoo, or even Blogger? You can already use it to login to other sites.

But why would you? Isn't login/password good enough?

Have you ever wondered whether the service you are logging to, uses one-way password hashing or keeps the password as open text? Most users do not create password per site because they cannot remember more than, let's say, three passwords. If one site keeps their password as open text, they are practically screwed. And more often than not, it's the site that has the worst (or non-existing) security. Welcome to hell: all your bases are belong to us.

By the way, I know of only one site, which does SHA hashing on the client side (in javascript), so that the server has no way of knowing what the real password is: sprezentuj.pl

Kudos for that, though it smells like overengineering a bit :)

OpenID is pretty simple, and the process description at Wikipedia is practically everything you need, but as they say, one picture is worth 1000 words, so let's look at a  picture. There is a loot you can find with google, but most of them are in big-arrows-pointing-some-boxes-notation, and I find easier to read a sequence diagram, so here is my take on it:

 All you need to know are the “parties” :
User – your John Smith
Browser – yeah, that's the Firefox John Smith is using
Website – like alterstory.com, where John Smith is not registered yet
OpenID provider – like gmail.com, where John Smith already has an account

The effect: John Smith is logged in gmail.com, and if he agrees, automatically logged in on alterstory.com

OpenID sequence diagram. Click to see full version.
Things to remember:
  1. OpenID doesn't say anything about how the user authenticates with provider. In case of John Smith it's the login/password he enters in gmail.com (or the cookie that is checked), but it can be ANYTHING else, including biometric information.

  2. OpenID requires the user to enter an URL, from which the provider can be found. Most providers have static URL for that, so you can see a “Login with google” button instead of a text input field.
    For Gmail it's https://www.google.com/accounts/o8/id. For Blogger it's your blog URL (e.g. solidcraft.eu).

  3. OpenID doesn't require giving ANY information about the user to the website (except some, usually random, identifier). You may however ask for additional information (like user email, full name and so on) in a process called attribute exchange, and the provider MAY give you that. Google for example will handle email and names.
    The problem is, the way you ask for that information (attributes) has several standards (schemas), which is a pain in the ass.

Ok, now, how to get it working with Spring Security?

It's not much more complicated than configuring form logging.

1. Put the HTML form on your page

For logging with text input, you should add:
<form id="openidLoginOptionForm" action="j_spring_openid_security_check" method="post" target="_top">
    <input id="openid_identifier" name="openid_identifier" maxlength="100" type="text" />
    <input type="submit" />
</form>
For loggin with google, all you need is:
<form action="j_spring_openid_security_check" id=”googleOpenId” method="post" target="_top">
    <input id="openid_identifier" name="openid_identifier"
           type="hidden"
  value="https://www.google.com/accounts/o8/id"/>
    <img src="/img/social/google.png" onClick="submitForm('googleOpenId')”/>
</form>

2. Configure http tag in IoC (security.xml)

Notice attribute exchange in here is configured so that Google will return some data.
<http >
    ...             
    <openid-login authentication-failure-handler-ref="openIDAuthenticationFailureHandler"                      
                  user-service-ref="userOpenIdDetailsService" always-use-default-target="true" default-target-url="/redirectAfterLogin/">                  
        <attribute-exchange>
            <openid-attribute name="axContactEmail" type="http://axschema.org/contact/email" required="true"/>
            <openid-attribute name="oiContactEmail" type="http://schema.openid.net/contact/email" required="true"/>
            <openid-attribute name="axNamePersonFullname" type="http://axschema.org/namePerson" required="true"/>
            <openid-attribute name="axNamePersonFriendlyName" type="http://axschema.org/namePerson/friendly" required="true"/>
            <openid-attribute name="axNamePersonFirstName" type="http://axschema.org/namePerson/first" required="true"/>
            <openid-attribute name="axNamePersonLastName" type="http://axschema.org/namePerson/last" required="true"/>
        </attribute-exchange>
    </openid-login>
</http>

3. Add beans and classes responsible for handling success and failure:

<beans:bean id="userOpenIdDetailsService" name="userOpenIdAuthenticationProvider" class="pl.touk.storytelling.infrastructure.services.authentication.openId.AuthenticationWithOpenIdUserDetailsGetter">
    <beans:constructor-arg index="0" ref="userRepository"/>
</beans:bean>        

<beans:bean id="openIDAuthenticationFailureHandler" class="pl.touk.storytelling.infrastructure.services.authentication.openId.OpenIDAuthenticationFailureHandler">
    <beans:constructor-arg index="0" ref="openIdRegistrationPageMountPath"/>
    <beans:constructor-arg index="1" ref="normalizedOpenIdAttributesBuilder"/>
</beans:bean>

<beans:bean id="normalizedOpenIdAttributesBuilder" class="pl.touk.storytelling.infrastructure.services.authentication.openId.NormalizedOpenIdAttributesBuilder">
    <beans:property name="emailAddressAttributeNames">
        <beans:set value-type="java.lang.String">
            <beans:value type="java.lang.String">axContactEmail</beans:value>
            <beans:value type="java.lang.String">oiContactEmail</beans:value>
        </beans:set>
    </beans:property>
    <beans:property name="firstNameAttributeNames">
        <beans:set value-type="java.lang.String">
            <beans:value type="java.lang.String">axNamePersonFirstName</beans:value>
        </beans:set>
    </beans:property>
    <beans:property name="lastNameAttributeNames">
        <beans:set value-type="java.lang.String">
            <beans:value type="java.lang.String">axNamePersonLastName</beans:value>
        </beans:set>
    </beans:property>
    <beans:property name="fullNameAttributeNames">
        <beans:set value-type="java.lang.String">
            <beans:value type="java.lang.String">axNamePersonFullname</beans:value>
            <beans:value type="java.lang.String">axNamePersonFriendlyName</beans:value>
        </beans:set>
    </beans:property>
</beans:bean>

AuthenticationWithOpenIdUserDetailsGetter is an implementation of UserDetailsService that returns the user basing on some ID given by OpenID provider (this is not the same as the login John Smith entered at Gmail, in case of Gmail it looks like a random string).

public class AuthenticationWithOpenIdUserDetailsGetter implements UserDetailsService {
    private UserRepository userRepository;

    // required by CGLIB
    protected AuthenticationWithOpenIdUserDetailsGetter() {
    }

    public AuthenticationWithOpenIdUserDetailsGetter(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException, DataAccessException {
        User user = userRepository.findByLoginOpenId(username);
        throwExceptionIfNotFound(user, username);
        return new AuthenticationUserDetails(user);
    }

    private void throwExceptionIfNotFound(User user, String loginOpenId) {
        if (user == null) {
            throw new UsernameNotFoundException("User with open id login " + loginOpenId + "  has not been found.");
        }
    }
}

OpenIDAuthenticationFailureHandler will be fired when AuthenticationWithOpenIdUserDetailsGetter can't find the user. That means, when the process of logging via google was fine, but the user is visiting your website for the first time, you don't have him yet in your database, hence AuthenticationWithOpenIdUserDetailsGetter throws exception. This is a signal, you want to register the user, and OpenIDAuthenticationFailureHandler will redirect to appropriate page, putting the date received from google (email, full name, etc.) in the session.

public class OpenIDAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    private String openIdRegistrationUrl;
    private NormalizedOpenIdAttributesBuilder normalizedOpenIdAttributesBuilder;

    public OpenIDAuthenticationFailureHandler(String openIdRegistrationUrl, NormalizedOpenIdAttributesBuilder normalizedOpenIdAttributesBuilder) {
        this.openIdRegistrationUrl = openIdRegistrationUrl;
        this.normalizedOpenIdAttributesBuilder = normalizedOpenIdAttributesBuilder;
    }

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        if (openIdAuthenticationSuccesfullButUserIsNotRegistered(exception)) {
            redirectToOpenIdRegistrationUrl(request, response, exception);
        } else {
            super.onAuthenticationFailure(request, response, exception);
        }
    }

    private void redirectToOpenIdRegistrationUrl(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        DefaultRedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
        addOpenIdAttributesToSession(request, getOpenIdAuthenticationToken(exception));
        redirectStrategy.sendRedirect(request, response, openIdRegistrationUrl);
    }

    private void addOpenIdAttributesToSession(HttpServletRequest request, OpenIDAuthenticationToken openIdAuthenticationToken) throws ServletException {
        HttpSession session = request.getSession(false);
        sessionShouldBePresent(session);
        NormalizedOpenIdAttributes normalizedOpenIdAttributes = normalizedOpenIdAttributesBuilder.build(openIdAuthenticationToken);
        session.setAttribute(SessionKeys.openIdAttributes, normalizedOpenIdAttributes);
    }

    private void sessionShouldBePresent(HttpSession session) throws ServletException {
        if (session == null) {
            throw new ServletException("No session found");
        }
    }

    private boolean openIdAuthenticationSuccesfullButUserIsNotRegistered(AuthenticationException exception) {
        return exception instanceof UsernameNotFoundException &&
                exception.getAuthentication() instanceof OpenIDAuthenticationToken &&
                OpenIDAuthenticationStatus.SUCCESS.equals((getOpenIdAuthenticationToken(exception)).getStatus());
    }

    private OpenIDAuthenticationToken getOpenIdAuthenticationToken(AuthenticationException exception) {
        return ((OpenIDAuthenticationToken) exception.getAuthentication());
    }

}

NormalizedOpenIdAttributesBuilder gets the date received from privder (google: email, full name, etc.) and normalizes it into some standard form (NormalizedOpenIdAttributes). This way we can easily handle differences with schema from different providers. Remember “pain in the ass” above?
public class NormalizedOpenIdAttributesBuilder {
    private Set<String> emailAddressAttributeNames = new HashSet<String>();
    private Set<String> firstNameAttributeNames = new HashSet<String>();
    private Set<String> lastNameAttributeNames = new HashSet<String>();
    private Set<String> fullNameAttributeNames = new HashSet<String>();

    public NormalizedOpenIdAttributes build(OpenIDAuthenticationToken openIdAuthenticationToken) {
        String userLocalIdentifier = openIdAuthenticationToken.getIdentityUrl();
        String emailAddress = setUpEmailAddress(openIdAuthenticationToken);
        String fullName = setUpFullName(openIdAuthenticationToken);
        String loginReplacement = setUpLoginReplacement(openIdAuthenticationToken);
        return new NormalizedOpenIdAttributes(userLocalIdentifier, emailAddress, fullName, loginReplacement);
    }

    private String setUpLoginReplacement(OpenIDAuthenticationToken openIdAuthenticationToken) {
        String separator = ".";
        return getNameFromFirstAndLast(openIdAuthenticationToken, separator).toLowerCase();
    }

    private String setUpEmailAddress(OpenIDAuthenticationToken openIdAuthenticationToken) {
        for (OpenIDAttribute openIDAttribute : openIdAuthenticationToken.getAttributes()) {
            if (setContainsAndAttributeHasValue(emailAddressAttributeNames, openIDAttribute)) {
                return openIDAttribute.getValues().get(0);
            }
        }
        return null;
    }

    private boolean setContainsAndAttributeHasValue(Set<String> emailAddressAttributeNames, OpenIDAttribute openIDAttribute) {
        return emailAddressAttributeNames.contains(openIDAttribute.getName()) && attributeHasValue(openIDAttribute);
    }

    private boolean attributeHasValue(OpenIDAttribute openIDAttribute) {
        return openIDAttribute.getValues() != null && openIDAttribute.getValues().size() > 0;
    }

    private String setUpFullName(OpenIDAuthenticationToken openIdAuthenticationToken) {
        String fullName = getAttributeValue(openIdAuthenticationToken, fullNameAttributeNames);
        if (fullName == null) {
            String separator = " ";
            fullName = getNameFromFirstAndLast(openIdAuthenticationToken, separator);
        }

        return fullName;
    }

    private String getAttributeValue(OpenIDAuthenticationToken openIdAuthenticationToken, Set<String> stringSet) {
        for (OpenIDAttribute openIDAttribute : openIdAuthenticationToken.getAttributes()) {
            if (attributeHasValue(openIDAttribute)) {
                if (stringSet.contains(openIDAttribute.getName())) {
                    return openIDAttribute.getValues().get(0);
                }
            }
        }
        return null;
    }

    private String getNameFromFirstAndLast(OpenIDAuthenticationToken openIdAuthenticationToken, String separator) {
        String firstName = getAttributeValue(openIdAuthenticationToken, firstNameAttributeNames);
        String lastName = getAttributeValue(openIdAuthenticationToken, lastNameAttributeNames);
        return StringUtils.join(new String[]{firstName, lastName}, separator);
    }

    public void setEmailAddressAttributeNames(Set<String> emailAddressAttributeNames) {
        this.emailAddressAttributeNames = emailAddressAttributeNames;
    }

    public void setFirstNameAttributeNames(Set<String> firstNameAttributeNames) {
        this.firstNameAttributeNames = firstNameAttributeNames;
    }

    public void setLastNameAttributeNames(Set<String> lastNameAttributeNames) {
        this.lastNameAttributeNames = lastNameAttributeNames;
    }

    public void setFullNameAttributeNames(Set<String> fullNameAttributeNames) {
        this.fullNameAttributeNames = fullNameAttributeNames;
    }
}

And the class with normalized attributes, that is going to HTTP session looks like this:
public class NormalizedOpenIdAttributes implements Serializable {
    private String userLocalIdentifier;
    private String emailAddress;
    private String fullName;
    private String loginReplacement;

    public NormalizedOpenIdAttributes(String userLocalIdentifier, String emailAddress, String fullName, String loginReplacement) {
        this.userLocalIdentifier = userLocalIdentifier;
        this.emailAddress = emailAddress;
        this.fullName = fullName;
        this.loginReplacement = loginReplacement;
    }

    public String getUserLocalIdentifier() {
        return userLocalIdentifier;
    }

    public String getEmailAddress() {
        return emailAddress;
    }

    public String getFullName() {
        return fullName;
    }

    public String getLoginReplacement() {
        return loginReplacement;
    }
}


You may wonder, why am I redirecting the user to registration page, with all his data filled in, instead of registering him automatically? Well, I think it's better for the user to be able to decide, what login/email/name he wants to use in your service. After all, not everybody wants to be identifiable by their true name.

Last thing to remember: NEVER redirect the user to the provider in iframe/modal window (use target=”_top”). If the user will not be 100% sure, he is on his provider web site (looking at the address bar, checking security), the first email you'll have will look like this:

“Dear administrators of {censored URL},
You are fucking dumbiest people ever, thinking I'll give you my google login and password.
Sincerely,
John Smith”

That's what happened to a friendly website. Seriously.

13 comments:

  1. Nice tutorial. When part6 and part7 are planned ?

    ReplyDelete
  2. @tlehoux: I'll try to get it done this weekend.

    ReplyDelete
  3. Great tutorial Jakub!

    Is there a way to produce the same effect as this combobox on the "Comment as"?

    I was wondering making a 'personal web blog application', but i didn't want to have a user entity on my application.
    Nowadays, i think it's difficult to people submit a form and login every time they want to make a comment or something like that.

    So, if the person is already logged on GMail or Facebook, the comment would be added by one of those - the user might choose, like this combobox.

    Keep the good work!
    Danilo - Brazil

    ReplyDelete
  4. @Balarini:
    Sure, if you've got OpenId and OAuth2 authentication in your application already, all you have to do after the POST is:
    - save the content of the form (comment) in user's session for saving later if the authentication is succesfull
    - for openID - redirect the user to j_spring_openid_security_check with openid_identifier property (for gmail it's https://www.google.com/accounts/o8/id)
    - for oauth2 - redirect the user to the provider with a return url and your app key (for facebook it's something like this:
    https://graph.facebook.com/oauth/authorize?client_id=117041165030192&display=page&redirect_uri=http://alterstory.com/facebookAuthentication

    @tlehoux: sorry I wanted to get the blog post done, but spring social 1.0.0.M3 came out (I was using 1.0.0.M1) and I decided to write a tutorial for the new version, after using it at my friend's startup. 1.0.0.M3 finally has some docs (1.0.0.M1 didn't), and as usually with spring source, these are really good, but to follow the the docs, I had to upgrade to spring 3.1.0.M1, and it took me a while to migrate my friend's startup from 2.5.6 to 3.1.0 :[

    ReplyDelete
  5. No problem Jakub. I can wait for your new tutorial with 1.0.0.M3.

    ReplyDelete
  6. Hi Jakub,
    Very nice article. Can you please post the complete source code for reference.

    ReplyDelete
  7. As for OpenID, there you go, good sir:
    https://github.com/has-learnt-it/haslearntit

    I don't have a separate project just for the show (which I should have), but I've just implemented OpenID integration with Spring 3.1.0.RC1 and Spring Security 3.0.5.RELEASE in there.

    ReplyDelete
  8. Nice article! When facebook part will be released? :)

    ReplyDelete
  9. Thanks for the article. Clear, precise, easy-to-read.

    ReplyDelete
  10. tks for the tutorial , please when part6 and part7 are planned ?

    ReplyDelete
  11. 10X, useful tutorial! Look forward seeing the next one (are we there yet? :))

    ReplyDelete
  12. My observation with spring 3.1.0 is that it may be better to take the successful authentication alternative because AuthenticationException.getAuthentication() is now deprecated.

    This means considering an unregistered openID user as a successful authentication, upon which the authentication object will be made available in the success handler.

    The user object can be populated with information (username, roles) to enable the success handler distinguish between existing authenticated users and those yet to register.

    From this point, the relevant registration or other page can be displayed in onAuthenticationSuccess()

    ReplyDelete