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 0000000000..707ad31295 --- /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 36a5252515..ac73087bca 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 Stian Thorgersen */ @@ -48,7 +49,20 @@ public interface LoginFormsProvider extends Provider { public LoginFormsProvider setAccessRequest(List realmRolesRequested, MultivaluedMap 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 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 9aca77f006..6d600f060c 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 httpResponseHeaders = new HashMap(); private String accessRequestMessage; private URI actionUri; - private Object[] parameters; - private String message; + private List messages = null; private MessageType messageType = MessageType.ERROR; private MultivaluedMap 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 messagesPerField = new HashMap(); + 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 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 72d48b7d88..f5e69c4145 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 + "
" + 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 5b405355a6..0a7e8b1df4 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 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 f72a63023a..5fb98f9d96 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 formData, List requiredCredentialTypes) { - if (isEmpty(formData.getFirst("firstName"))) { - return Messages.MISSING_FIRST_NAME; + public static List validateRegistrationForm(RealmModel realm, MultivaluedMap formData, List requiredCredentialTypes, PasswordPolicy policy) { + List errors = new ArrayList<>(); + + if (!realm.isRegistrationEmailAsUsername() && isEmpty(formData.getFirst(FIELD_USERNAME))) { + addError(errors, FIELD_USERNAME, Messages.MISSING_USERNAME); } - if (isEmpty(formData.getFirst("lastName"))) { - return Messages.MISSING_LAST_NAME; + if (isEmpty(formData.getFirst(FIELD_FIRST_NAME))) { + addError(errors, FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME); } - if (isEmpty(formData.getFirst("email"))) { - return Messages.MISSING_EMAIL; + if (isEmpty(formData.getFirst(FIELD_LAST_NAME))) { + addError(errors, FIELD_LAST_NAME, Messages.MISSING_LAST_NAME); } - if (!isEmailValid(formData.getFirst("email"))) { - return Messages.INVALID_EMAIL; - } - - 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; + } + + private static void addError(List errors, String field, String message){ + errors.add(new FormMessage(field, message)); } - public static PasswordPolicy.Error validatePassword(MultivaluedMap formData, PasswordPolicy policy) { - return policy.validate(formData.getFirst("username"), formData.getFirst("password")); - } public static String validateUpdateProfileForm(MultivaluedMap 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; }