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;
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,8 +49,21 @@ 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);
public LoginFormsProvider setWarning(String message, Object ... parameters);

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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)

View file

@ -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 (!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;
}
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;
}