KEYCLOAK-1113 - LoginFormProvider extended to allow per field errors,

freemarker implementation extended (backward compatible), used for
registration form
This commit is contained in:
Vlastimil Elias 2015-03-27 15:41:42 +01:00
parent 48050de1a6
commit 8727aef647
6 changed files with 213 additions and 78 deletions

View file

@ -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) + "]";
}
}

View file

@ -1,5 +1,13 @@
package org.keycloak.login; 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.ClientModel;
import org.keycloak.models.ClientSessionModel; import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
@ -7,13 +15,6 @@ import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.provider.Provider; 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> * @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(List<RoleModel> realmRolesRequested, MultivaluedMap<String,RoleModel> resourceRolesRequested);
public LoginFormsProvider setAccessRequest(String message); 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); 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); public LoginFormsProvider setSuccess(String message, Object ... parameters);

View file

@ -8,6 +8,7 @@ import org.keycloak.email.EmailProvider;
import org.keycloak.freemarker.*; import org.keycloak.freemarker.*;
import org.keycloak.freemarker.beans.AdvancedMessageFormatterMethod; import org.keycloak.freemarker.beans.AdvancedMessageFormatterMethod;
import org.keycloak.freemarker.beans.MessageFormatterMethod; import org.keycloak.freemarker.beans.MessageFormatterMethod;
import org.keycloak.login.FormMessage;
import org.keycloak.login.LoginFormsPages; import org.keycloak.login.LoginFormsPages;
import org.keycloak.login.LoginFormsProvider; import org.keycloak.login.LoginFormsProvider;
import org.keycloak.login.freemarker.model.ClientBean; 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 org.keycloak.services.resources.flows.Urls;
import javax.ws.rs.core.*; import javax.ws.rs.core.*;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.text.MessageFormat; import java.text.MessageFormat;
@ -55,9 +57,8 @@ import java.util.concurrent.TimeUnit;
private Map<String, String> httpResponseHeaders = new HashMap<String, String>(); private Map<String, String> httpResponseHeaders = new HashMap<String, String>();
private String accessRequestMessage; private String accessRequestMessage;
private URI actionUri; private URI actionUri;
private Object[] parameters;
private String message; private List<FormMessage> messages = null;
private MessageType messageType = MessageType.ERROR; private MessageType messageType = MessageType.ERROR;
private MultivaluedMap<String, String> formData; private MultivaluedMap<String, String> formData;
@ -134,7 +135,7 @@ import java.util.concurrent.TimeUnit;
return Response.serverError().build(); return Response.serverError().build();
} }
if (message == null) { if (messages == null) {
setWarning(actionMessage); setWarning(actionMessage);
} }
@ -175,24 +176,34 @@ import java.util.concurrent.TimeUnit;
logger.warn("Failed to load properties", e); logger.warn("Failed to load properties", e);
} }
Properties messages; Properties messagesBundle;
Locale locale = LocaleHelper.getLocale(realm, user, uriInfo, httpHeaders); Locale locale = LocaleHelper.getLocale(realm, user, uriInfo, httpHeaders);
try { try {
messages = theme.getMessages(locale); messagesBundle = theme.getMessages(locale);
attributes.put("msg", new MessageFormatterMethod(locale, messages)); attributes.put("msg", new MessageFormatterMethod(locale, messagesBundle));
} catch (IOException e) { } catch (IOException e) {
logger.warn("Failed to load messages", e); logger.warn("Failed to load messages", e);
messages = new Properties(); messagesBundle = new Properties();
} }
if (message != null) { if (messages != null) {
String formattedMessage; Map<String, MessageBean> messagesPerField = new HashMap<String, MessageBean>();
if(messages.containsKey(message)){ MessageBean wholeMessage = new MessageBean(null, messageType);
formattedMessage = new MessageFormat(messages.getProperty(message),locale).format(parameters); for (FormMessage message : this.messages) {
}else{ String formattedMessageText = formatMessageMessage(message, messagesBundle, locale);
formattedMessage = message; 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) { 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 // 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()); b = UriBuilder.fromUri(baseUri).path(uriInfo.getPath());
break; 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; break;
case OAUTH_GRANT: case OAUTH_GRANT:
attributes.put("oauth", new OAuthGrantBean(accessCode, clientSession, client, realmRolesRequested, resourceRolesRequested, this.accessRequestMessage)); 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; break;
case CODE: 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; break;
} }
@ -303,24 +314,46 @@ import java.util.concurrent.TimeUnit;
return createResponse(LoginFormsPages.CODE); return createResponse(LoginFormsPages.CODE);
} }
public FreeMarkerLoginFormsProvider setError(String message, Object ... parameters) { protected void setMessage(MessageType type, String message, Object... parameters) {
this.message = message; 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.messageType = MessageType.ERROR;
this.parameters = parameters; this.messages = new ArrayList<>(messages);
return this; return this;
} }
public FreeMarkerLoginFormsProvider setSuccess(String message, Object ... parameters) { public FreeMarkerLoginFormsProvider setSuccess(String message, Object ... parameters) {
this.message = message; setMessage(MessageType.SUCCESS, message, parameters);
this.messageType = MessageType.SUCCESS;
this.parameters = parameters;
return this; return this;
} }
public FreeMarkerLoginFormsProvider setWarning(String message, Object ... parameters) { public FreeMarkerLoginFormsProvider setWarning(String message, Object ... parameters) {
this.message = message; setMessage(MessageType.WARNING, message, parameters);
this.messageType = MessageType.WARNING;
this.parameters = parameters;
return this; return this;
} }

View file

@ -41,6 +41,15 @@ public class MessageBean {
return summary; return summary;
} }
public void appendSummaryLine(String newLine) {
if (newLine == null)
return;
if (summary == null)
summary = newLine;
else
summary = summary + "<br>" + newLine;
}
public String getType() { public String getType() {
return this.type.toString().toLowerCase(); return this.type.toString().toLowerCase();
} }

View file

@ -31,6 +31,7 @@ import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType; import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSBuilder; import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.login.FormMessage;
import org.keycloak.login.LoginFormsProvider; import org.keycloak.login.LoginFormsProvider;
import org.keycloak.models.*; import org.keycloak.models.*;
import org.keycloak.models.UserModel.RequiredAction; 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.UriBuilder;
import javax.ws.rs.core.UriInfo; import javax.ws.rs.core.UriInfo;
import javax.ws.rs.ext.Providers; import javax.ws.rs.ext.Providers;
import java.util.ArrayList;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -422,8 +425,8 @@ public class LoginActionsService {
return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, headers, Messages.INVALID_CODE); return Flows.forwardToSecurityFailurePage(session, realm, uriInfo, headers, Messages.INVALID_CODE);
} }
String username = formData.getFirst("username"); String username = formData.getFirst(Validation.FIELD_USERNAME);
String email = formData.getFirst("email"); String email = formData.getFirst(Validation.FIELD_EMAIL);
if (realm.isRegistrationEmailAsUsername()) { if (realm.isRegistrationEmailAsUsername()) {
username = email; username = email;
formData.putSingle(AuthenticationManager.FORM_USERNAME, username); 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 // Validate here, so user is not created if password doesn't validate to passwordPolicy of current realm
String errorMessage = Validation.validateRegistrationForm(realm, formData, requiredCredentialTypes); List<FormMessage> errors = Validation.validateRegistrationForm(realm, formData, requiredCredentialTypes, realm.getPasswordPolicy());
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();
}
}
if (errorMessage != null) { if (errors != null && !errors.isEmpty()) {
event.error(Errors.INVALID_REGISTRATION); event.error(Errors.INVALID_REGISTRATION);
return Flows.forms(session, realm, client, uriInfo, headers) return Flows.forms(session, realm, client, uriInfo, headers)
.setError(errorMessage, parameters) .setErrors(errors)
.setFormData(formData) .setFormData(formData)
.setClientSessionCode(clientCode.getCode()) .setClientSessionCode(clientCode.getCode())
.createRegistration(); .createRegistration();
@ -488,7 +483,7 @@ public class LoginActionsService {
} }
// Validate that user with this email doesn't exist in realm or any federation provider // 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); event.error(Errors.EMAIL_IN_USE);
return Flows.forms(session, realm, client, uriInfo, headers) return Flows.forms(session, realm, client, uriInfo, headers)
.setError(Messages.EMAIL_EXISTS) .setError(Messages.EMAIL_EXISTS)

View file

@ -1,71 +1,86 @@
package org.keycloak.services.validation; 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.PasswordPolicy;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.representations.idm.CredentialRepresentation; import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.messages.Messages; 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 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() // 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-]+)*"); 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) { public static List<FormMessage> validateRegistrationForm(RealmModel realm, MultivaluedMap<String, String> formData, List<String> requiredCredentialTypes, PasswordPolicy policy) {
if (isEmpty(formData.getFirst("firstName"))) { List<FormMessage> errors = new ArrayList<>();
return Messages.MISSING_FIRST_NAME;
if (!realm.isRegistrationEmailAsUsername() && isEmpty(formData.getFirst(FIELD_USERNAME))) {
addError(errors, FIELD_USERNAME, Messages.MISSING_USERNAME);
} }
if (isEmpty(formData.getFirst("lastName"))) { if (isEmpty(formData.getFirst(FIELD_FIRST_NAME))) {
return Messages.MISSING_LAST_NAME; addError(errors, FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME);
} }
if (isEmpty(formData.getFirst("email"))) { if (isEmpty(formData.getFirst(FIELD_LAST_NAME))) {
return Messages.MISSING_EMAIL; addError(errors, FIELD_LAST_NAME, Messages.MISSING_LAST_NAME);
} }
if (!isEmailValid(formData.getFirst("email"))) { if (isEmpty(formData.getFirst(FIELD_EMAIL))) {
return Messages.INVALID_EMAIL; addError(errors, FIELD_EMAIL, Messages.MISSING_EMAIL);
} } else if (!isEmailValid(formData.getFirst(FIELD_EMAIL))) {
addError(errors, FIELD_EMAIL, Messages.INVALID_EMAIL);
if (!realm.isRegistrationEmailAsUsername() && isEmpty(formData.getFirst("username"))) {
return Messages.MISSING_USERNAME;
} }
if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) { if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) {
if (isEmpty(formData.getFirst(CredentialRepresentation.PASSWORD))) { if (isEmpty(formData.getFirst(FIELD_PASSWORD))) {
return Messages.MISSING_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);
if (!formData.getFirst("password").equals(formData.getFirst("password-confirm"))) {
return 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<FormMessage> errors, String field, String message){
errors.add(new FormMessage(field, message));
} }
public static PasswordPolicy.Error validatePassword(MultivaluedMap<String, String> formData, PasswordPolicy policy) {
return policy.validate(formData.getFirst("username"), formData.getFirst("password"));
}
public static String validateUpdateProfileForm(MultivaluedMap<String, String> formData) { 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; return Messages.MISSING_FIRST_NAME;
} }
if (isEmpty(formData.getFirst("lastName"))) { if (isEmpty(formData.getFirst(FIELD_LAST_NAME))) {
return Messages.MISSING_LAST_NAME; return Messages.MISSING_LAST_NAME;
} }
if (isEmpty(formData.getFirst("email"))) { if (isEmpty(formData.getFirst(FIELD_EMAIL))) {
return Messages.MISSING_EMAIL; return Messages.MISSING_EMAIL;
} }
if (!isEmailValid(formData.getFirst("email"))) { if (!isEmailValid(formData.getFirst(FIELD_EMAIL))) {
return Messages.INVALID_EMAIL; return Messages.INVALID_EMAIL;
} }