keycloak-uncached

KEYCLOAK-1113 - LoginFormProvider extended to allow per field

3/27/2015 11:41:42 AM

Details

diff --git a/forms/login-api/src/main/java/org/keycloak/login/FormMessage.java b/forms/login-api/src/main/java/org/keycloak/login/FormMessage.java
new file mode 100644
index 0000000..707ad31
--- /dev/null
+++ b/forms/login-api/src/main/java/org/keycloak/login/FormMessage.java
@@ -0,0 +1,69 @@
+/*
+ * JBoss, Home of Professional Open Source
+ * Copyright 2015 Red Hat Inc. and/or its affiliates and other contributors
+ * as indicated by the @authors tag. All rights reserved.
+ */
+package org.keycloak.login;
+
+import java.util.Arrays;
+
+/**
+ * Message (eg. error) to be shown in form.
+ * 
+ * @author Vlastimil Elias (velias at redhat dot com)
+ */
+public class FormMessage {
+
+	/**
+	 * Value used for {@link #field} if message is global (not tied to any specific form field)
+	 */
+	public static final String GLOBAL = "global";
+
+	private String field;
+	private String message;
+	private Object[] parameters;
+
+	/**
+	 * Create message.
+	 * 
+	 * @param field this message is for. {@link #GLOBAL} is used if null
+	 * @param message key for the message
+	 * @param parameters to be formatted into message
+	 */
+	public FormMessage(String field, String message, Object... parameters) {
+		this(field, message);
+		this.parameters = parameters;
+	}
+	
+	/**
+     * Create message without parameters.
+     * 
+     * @param field this message is for. {@link #GLOBAL} is used if null
+     * @param message key for the message
+     */
+    public FormMessage(String field, String message) {
+        super();
+        if (field == null)
+            field = GLOBAL;
+        this.field = field;
+        this.message = message;
+    }
+
+	public String getField() {
+		return field;
+	}
+
+	public String getMessage() {
+		return message;
+	}
+
+	public Object[] getParameters() {
+		return parameters;
+	}
+
+	@Override
+	public String toString() {
+		return "FormMessage [field=" + field + ", message=" + message + ", parameters=" + Arrays.toString(parameters) + "]";
+	}
+
+}
diff --git a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java
index 36a5252..ac73087 100755
--- a/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java
+++ b/forms/login-api/src/main/java/org/keycloak/login/LoginFormsProvider.java
@@ -1,5 +1,13 @@
 package org.keycloak.login;
 
+import java.net.URI;
+import java.util.List;
+
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+
 import org.keycloak.models.ClientModel;
 import org.keycloak.models.ClientSessionModel;
 import org.keycloak.models.RealmModel;
@@ -7,13 +15,6 @@ import org.keycloak.models.RoleModel;
 import org.keycloak.models.UserModel;
 import org.keycloak.provider.Provider;
 
-import javax.ws.rs.core.HttpHeaders;
-import javax.ws.rs.core.MultivaluedMap;
-import javax.ws.rs.core.Response;
-import javax.ws.rs.core.UriInfo;
-import java.net.URI;
-import java.util.List;
-
 /**
  * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
  */
@@ -48,7 +49,20 @@ public interface LoginFormsProvider extends Provider {
     public LoginFormsProvider setAccessRequest(List<RoleModel> realmRolesRequested, MultivaluedMap<String,RoleModel> resourceRolesRequested);
     public LoginFormsProvider setAccessRequest(String message);
 
+    /**
+     * Set one global error message.
+     * 
+     * @param message key of message
+     * @param parameters to be formatted into message
+     */
     public LoginFormsProvider setError(String message, Object ... parameters);
+    
+    /**
+     * Set multiple error messages.
+     * 
+     * @param messages to be set
+     */
+    public LoginFormsProvider setErrors(List<FormMessage> messages);
 
     public LoginFormsProvider setSuccess(String message, Object ... parameters);
 
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java
index 9aca77f..6d600f0 100755
--- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/FreeMarkerLoginFormsProvider.java
@@ -8,6 +8,7 @@ import org.keycloak.email.EmailProvider;
 import org.keycloak.freemarker.*;
 import org.keycloak.freemarker.beans.AdvancedMessageFormatterMethod;
 import org.keycloak.freemarker.beans.MessageFormatterMethod;
+import org.keycloak.login.FormMessage;
 import org.keycloak.login.LoginFormsPages;
 import org.keycloak.login.LoginFormsProvider;
 import org.keycloak.login.freemarker.model.ClientBean;
@@ -32,6 +33,7 @@ import org.keycloak.services.messages.Messages;
 import org.keycloak.services.resources.flows.Urls;
 
 import javax.ws.rs.core.*;
+
 import java.io.IOException;
 import java.net.URI;
 import java.text.MessageFormat;
@@ -55,9 +57,8 @@ import java.util.concurrent.TimeUnit;
     private Map<String, String> httpResponseHeaders = new HashMap<String, String>();
     private String accessRequestMessage;
     private URI actionUri;
-    private Object[] parameters;
 
-    private String message;
+    private List<FormMessage> messages = null;
     private MessageType messageType = MessageType.ERROR;
 
     private MultivaluedMap<String, String> formData;
@@ -134,7 +135,7 @@ import java.util.concurrent.TimeUnit;
                 return Response.serverError().build();
         }
 
-        if (message == null) {
+        if (messages == null) {
             setWarning(actionMessage);
         }
 
@@ -175,24 +176,34 @@ import java.util.concurrent.TimeUnit;
             logger.warn("Failed to load properties", e);
         }
 
-        Properties messages;
+        Properties messagesBundle;
         Locale locale = LocaleHelper.getLocale(realm, user, uriInfo, httpHeaders);
         try {
-            messages = theme.getMessages(locale);
-            attributes.put("msg", new MessageFormatterMethod(locale, messages));
+            messagesBundle = theme.getMessages(locale);
+            attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle));
         } catch (IOException e) {
             logger.warn("Failed to load messages", e);
-            messages = new Properties();
+            messagesBundle = new Properties();
         }
 
-        if (message != null) {
-            String formattedMessage;
-            if(messages.containsKey(message)){
-                formattedMessage = new MessageFormat(messages.getProperty(message),locale).format(parameters);
-            }else{
-                formattedMessage = message;
+        if (messages != null) {
+            Map<String, MessageBean> messagesPerField = new HashMap<String, MessageBean>();
+            MessageBean wholeMessage = new MessageBean(null, messageType);
+            for (FormMessage message : this.messages) {
+                String formattedMessageText = formatMessageMessage(message, messagesBundle, locale);
+                if (formattedMessageText != null) {
+                    wholeMessage.appendSummaryLine(formattedMessageText);
+                    MessageBean fm = messagesPerField.get(message.getField());
+                    if (fm == null) {
+                        messagesPerField.put(message.getField(), new MessageBean(formattedMessageText, messageType));
+                    } else {
+                        fm.appendSummaryLine(formattedMessageText);
+                    }
+                }
             }
-            attributes.put("message", new MessageBean(formattedMessage, messageType));
+            
+            attributes.put("message", wholeMessage);
+            attributes.put("messagePerField", messagesPerField);
         }
         if (page == LoginFormsPages.OAUTH_GRANT) {
             // for some reason Resteasy 2.3.7 doesn't like query params and form params with the same name and will null out the code form param
@@ -218,7 +229,7 @@ import java.util.concurrent.TimeUnit;
                         b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath());
                         break;
                 }
-                attributes.put("locale", new LocaleBean(realm, locale, b, messages));
+                attributes.put("locale", new LocaleBean(realm, locale, b, messagesBundle));
             }
         }
 
@@ -240,10 +251,10 @@ import java.util.concurrent.TimeUnit;
                 break;
             case OAUTH_GRANT:
                 attributes.put("oauth", new OAuthGrantBean(accessCode, clientSession, client, realmRolesRequested, resourceRolesRequested, this.accessRequestMessage));
-                attributes.put("advancedMsg", new AdvancedMessageFormatterMethod(locale, messages));
+                attributes.put("advancedMsg", new AdvancedMessageFormatterMethod(locale, messagesBundle));
                 break;
             case CODE:
-                attributes.put(OAuth2Constants.CODE, new CodeBean(accessCode, messageType == MessageType.ERROR ? message : null));
+                attributes.put(OAuth2Constants.CODE, new CodeBean(accessCode, messageType == MessageType.ERROR ? getFirstMessageUnformatted() : null));
                 break;
         }
 
@@ -303,24 +314,46 @@ import java.util.concurrent.TimeUnit;
         return createResponse(LoginFormsPages.CODE);
     }
 
-    public FreeMarkerLoginFormsProvider setError(String message, Object ... parameters) {
-        this.message = message;
+    protected void setMessage(MessageType type, String message, Object... parameters) {
+        messageType = type;
+        messages = new ArrayList<>();
+        messages.add(new FormMessage(null, message, parameters));
+    }
+
+    protected String getFirstMessageUnformatted() {
+        if (messages != null && !messages.isEmpty()) {
+            return messages.get(0).getMessage();
+        }
+        return null;
+    }
+
+    protected String formatMessageMessage(FormMessage message, Properties messagesBundle, Locale locale) {
+        if (message == null)
+            return null;
+        if (messagesBundle.containsKey(message.getMessage())) {
+            return new MessageFormat(messagesBundle.getProperty(message.getMessage()), locale).format(message.getParameters());
+        } else {
+            return message.getMessage();
+        }
+    }
+    public FreeMarkerLoginFormsProvider setError(String message, Object... parameters) {
+        setMessage(MessageType.ERROR, message, parameters);
+        return this;
+    }
+
+    public LoginFormsProvider setErrors(List<FormMessage> messages) {
         this.messageType = MessageType.ERROR;
-        this.parameters = parameters;
+        this.messages = new ArrayList<>(messages);
         return this;
     }
 
     public FreeMarkerLoginFormsProvider setSuccess(String message, Object ... parameters) {
-        this.message = message;
-        this.messageType = MessageType.SUCCESS;
-        this.parameters = parameters;
+        setMessage(MessageType.SUCCESS, message, parameters);
         return this;
     }
 
     public FreeMarkerLoginFormsProvider setWarning(String message, Object ... parameters) {
-        this.message = message;
-        this.messageType = MessageType.WARNING;
-        this.parameters = parameters;
+        setMessage(MessageType.WARNING, message, parameters);
         return this;
     }
 
diff --git a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/MessageBean.java b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/MessageBean.java
index 72d48b7..f5e69c4 100644
--- a/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/MessageBean.java
+++ b/forms/login-freemarker/src/main/java/org/keycloak/login/freemarker/model/MessageBean.java
@@ -41,6 +41,15 @@ public class MessageBean {
         return summary;
     }
 
+    public void appendSummaryLine(String newLine) {
+        if (newLine == null)
+            return;
+        if (summary == null)
+            summary = newLine;
+        else
+            summary = summary + "<br>" + newLine;
+    }
+
     public String getType() {
         return this.type.toString().toLowerCase();
     }
diff --git a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
index 5b40535..0a7e8b1 100755
--- a/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
+++ b/services/src/main/java/org/keycloak/services/resources/LoginActionsService.java
@@ -31,6 +31,7 @@ import org.keycloak.events.Errors;
 import org.keycloak.events.EventBuilder;
 import org.keycloak.events.EventType;
 import org.keycloak.jose.jws.JWSBuilder;
+import org.keycloak.login.FormMessage;
 import org.keycloak.login.LoginFormsProvider;
 import org.keycloak.models.*;
 import org.keycloak.models.UserModel.RequiredAction;
@@ -63,6 +64,8 @@ import javax.ws.rs.core.Response;
 import javax.ws.rs.core.UriBuilder;
 import javax.ws.rs.core.UriInfo;
 import javax.ws.rs.ext.Providers;
+
+import java.util.ArrayList;
 import java.util.LinkedList;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
@@ -422,8 +425,8 @@ public class LoginActionsService {
             return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, headers, Messages.INVALID_CODE);
         }
 
-        String username = formData.getFirst("username");
-        String email = formData.getFirst("email");
+        String username = formData.getFirst(Validation.FIELD_USERNAME);
+        String email = formData.getFirst(Validation.FIELD_EMAIL);
         if (realm.isRegistrationEmailAsUsername()) {
             username = email;
             formData.putSingle(AuthenticationManager.FORM_USERNAME, username);
@@ -458,20 +461,12 @@ public class LoginActionsService {
         }
 
         // Validate here, so user is not created if password doesn't validate to passwordPolicy of current realm
-        String errorMessage = Validation.validateRegistrationForm(realm, formData, requiredCredentialTypes);
-        Object[] parameters = new Object[0];
-        if (errorMessage == null) {
-            PasswordPolicy.Error error = Validation.validatePassword(formData, realm.getPasswordPolicy());
-            if(error != null){
-                errorMessage = error.getMessage();
-                parameters = error.getParameters();
-            }
-        }
+        List<FormMessage> errors = Validation.validateRegistrationForm(realm, formData, requiredCredentialTypes, realm.getPasswordPolicy());
 
-        if (errorMessage != null) {
+        if (errors != null && !errors.isEmpty()) {
             event.error(Errors.INVALID_REGISTRATION);
             return Flows.forms(session, realm, client, uriInfo, headers)
-                    .setError(errorMessage, parameters)
+                    .setErrors(errors)
                     .setFormData(formData)
                     .setClientSessionCode(clientCode.getCode())
                     .createRegistration();
@@ -488,7 +483,7 @@ public class LoginActionsService {
         }
 
         // Validate that user with this email doesn't exist in realm or any federation provider
-        if (session.users().getUserByEmail(email, realm) != null) {
+        if (email != null && session.users().getUserByEmail(email, realm) != null) {
             event.error(Errors.EMAIL_IN_USE);
             return Flows.forms(session, realm, client, uriInfo, headers)
                     .setError(Messages.EMAIL_EXISTS)
diff --git a/services/src/main/java/org/keycloak/services/validation/Validation.java b/services/src/main/java/org/keycloak/services/validation/Validation.java
index f72a630..5fb98f9 100755
--- a/services/src/main/java/org/keycloak/services/validation/Validation.java
+++ b/services/src/main/java/org/keycloak/services/validation/Validation.java
@@ -1,71 +1,86 @@
 package org.keycloak.services.validation;
 
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+import javax.ws.rs.core.MultivaluedMap;
+
+import org.keycloak.login.FormMessage;
 import org.keycloak.models.PasswordPolicy;
 import org.keycloak.models.RealmModel;
 import org.keycloak.representations.idm.CredentialRepresentation;
 import org.keycloak.services.messages.Messages;
 
-import javax.ws.rs.core.MultivaluedMap;
-import java.util.List;
-import java.util.regex.Pattern;
-
 public class Validation {
 
+    public static final String FIELD_PASSWORD_CONFIRM = "password-confirm";
+    public static final String FIELD_EMAIL = "email";
+    public static final String FIELD_LAST_NAME = "lastName";
+    public static final String FIELD_FIRST_NAME = "firstName";
+    public static final String FIELD_PASSWORD = "password";
+    public static final String FIELD_USERNAME = "username";
+    
     // Actually allow same emails like angular. See ValidationTest.testEmailValidation()
     private static final Pattern EMAIL_PATTERN = Pattern.compile("[a-zA-Z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-zA-Z0-9-]+(\\.[a-zA-Z0-9-]+)*");
 
-    public static String validateRegistrationForm(RealmModel realm, MultivaluedMap<String, String> formData, List<String> requiredCredentialTypes) {
-        if (isEmpty(formData.getFirst("firstName"))) {
-            return Messages.MISSING_FIRST_NAME;
-        }
+    public static List<FormMessage> validateRegistrationForm(RealmModel realm, MultivaluedMap<String, String> formData, List<String> requiredCredentialTypes, PasswordPolicy policy) {
+        List<FormMessage> errors = new ArrayList<>();
 
-        if (isEmpty(formData.getFirst("lastName"))) {
-            return Messages.MISSING_LAST_NAME;
+        if (!realm.isRegistrationEmailAsUsername() && isEmpty(formData.getFirst(FIELD_USERNAME))) {
+            addError(errors, FIELD_USERNAME, Messages.MISSING_USERNAME);
         }
 
-        if (isEmpty(formData.getFirst("email"))) {
-            return Messages.MISSING_EMAIL;
+        if (isEmpty(formData.getFirst(FIELD_FIRST_NAME))) {
+            addError(errors, FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME);
         }
 
-        if (!isEmailValid(formData.getFirst("email"))) {
-            return Messages.INVALID_EMAIL;
+        if (isEmpty(formData.getFirst(FIELD_LAST_NAME))) {
+            addError(errors, FIELD_LAST_NAME, Messages.MISSING_LAST_NAME);
         }
 
-        if (!realm.isRegistrationEmailAsUsername() && isEmpty(formData.getFirst("username"))) {
-            return Messages.MISSING_USERNAME;
+        if (isEmpty(formData.getFirst(FIELD_EMAIL))) {
+            addError(errors, FIELD_EMAIL, Messages.MISSING_EMAIL);
+        } else if (!isEmailValid(formData.getFirst(FIELD_EMAIL))) {
+            addError(errors, FIELD_EMAIL, Messages.INVALID_EMAIL);
         }
 
         if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) {
-            if (isEmpty(formData.getFirst(CredentialRepresentation.PASSWORD))) {
-                return Messages.MISSING_PASSWORD;
-            }
-
-            if (!formData.getFirst("password").equals(formData.getFirst("password-confirm"))) {
-                return Messages.INVALID_PASSWORD_CONFIRM;
+            if (isEmpty(formData.getFirst(FIELD_PASSWORD))) {
+                addError(errors, FIELD_PASSWORD, Messages.MISSING_PASSWORD);
+            } else if (!formData.getFirst(FIELD_PASSWORD).equals(formData.getFirst(FIELD_PASSWORD_CONFIRM))) {
+                addError(errors, FIELD_PASSWORD_CONFIRM, Messages.INVALID_PASSWORD_CONFIRM);
             }
         }
 
-        return null;
+        if (formData.getFirst(FIELD_PASSWORD) != null) {
+            PasswordPolicy.Error err = policy.validate(realm.isRegistrationEmailAsUsername()?formData.getFirst(FIELD_EMAIL):formData.getFirst(FIELD_USERNAME), formData.getFirst(FIELD_PASSWORD));
+            if (err != null)
+                errors.add(new FormMessage(FIELD_PASSWORD, err.getMessage(), err.getParameters()));
+        }
+        
+        return errors;
     }
-
-    public static PasswordPolicy.Error validatePassword(MultivaluedMap<String, String> formData, PasswordPolicy policy) {
-        return policy.validate(formData.getFirst("username"), formData.getFirst("password"));
+    
+    private static void addError(List<FormMessage> errors, String field, String message){
+        errors.add(new FormMessage(field, message));
     }
 
+
     public static String validateUpdateProfileForm(MultivaluedMap<String, String> formData) {
-        if (isEmpty(formData.getFirst("firstName"))) {
+        if (isEmpty(formData.getFirst(FIELD_FIRST_NAME))) {
             return Messages.MISSING_FIRST_NAME;
         }
 
-        if (isEmpty(formData.getFirst("lastName"))) {
+        if (isEmpty(formData.getFirst(FIELD_LAST_NAME))) {
             return Messages.MISSING_LAST_NAME;
         }
 
-        if (isEmpty(formData.getFirst("email"))) {
+        if (isEmpty(formData.getFirst(FIELD_EMAIL))) {
             return Messages.MISSING_EMAIL;
         }
 
-        if (!isEmailValid(formData.getFirst("email"))) {
+        if (!isEmailValid(formData.getFirst(FIELD_EMAIL))) {
             return Messages.INVALID_EMAIL;
         }