KEYCLOAK-44

Add configuration of TOTP to registration
This commit is contained in:
Stian Thorgersen 2013-08-16 15:51:30 +01:00
parent 5434b66b3a
commit d2621c452e
41 changed files with 242 additions and 74 deletions

View file

@ -13,6 +13,11 @@
<description />
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
@ -23,9 +28,14 @@
<artifactId>keycloak-social-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.picketlink</groupId>
<artifactId>picketlink-common</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.jboss.resteasy</groupId>
<artifactId>jaxrs-api</artifactId>
<artifactId>resteasy-jaxrs</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
@ -38,6 +48,14 @@
<artifactId>jsf-api</artifactId>
<version>2.1</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
</dependency>
</dependencies>
<build>

View file

@ -21,12 +21,15 @@
*/
package org.keycloak.forms;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import javax.annotation.PostConstruct;
import javax.faces.bean.ManagedBean;
@ -37,10 +40,12 @@ import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.UriBuilder;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.models.RealmModel;
import org.keycloak.services.models.RequiredCredentialModel;
import org.keycloak.services.resources.flows.FormFlows;
import org.keycloak.services.resources.flows.Urls;
import org.picketlink.common.util.Base32;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
@ -81,6 +86,10 @@ public class FormsBean {
private Map<String, String> formData;
private String totpSecret;
private String formsUrl;
@PostConstruct
public void init() {
FacesContext ctx = FacesContext.getCurrentInstance();
@ -125,9 +134,11 @@ public class FormsBean {
addSocialProviders();
addErrors(request);
formsUrl = FacesContext.getCurrentInstance().getExternalContext().getRequestContextPath() + "/forms";
// TODO Get theme name from realm
theme = "default";
themeUrl = FacesContext.getCurrentInstance().getExternalContext().getRequestContextPath() + "/sdk/theme/" + theme;
themeUrl = formsUrl + "/theme/" + theme;
themeConfig = new HashMap<String, Object>();
@ -220,9 +231,33 @@ public class FormsBean {
for (RequiredCredentialModel m : realm.getRequiredCredentials()) {
if (m.isInput()) {
requiredCredentials.add(new RequiredCredential(m.getType(), m.isSecret(), m.getFormLabel()));
if (m.getType().equals(CredentialRepresentation.TOTP)) {
if (formData != null) {
totpSecret = formData.get("totpSecret");
}
if (totpSecret == null) {
totpSecret = UUID.randomUUID().toString();
}
}
}
}
}
public boolean isTotp() {
return totpSecret != null;
}
public String getTotpSecret() {
return totpSecret;
}
public String getTotpSecretQrCodeUrl() throws UnsupportedEncodingException {
String totpSecretEncoded = Base32.encode(getTotpSecret().getBytes());
String contents = URLEncoder.encode("otpauth://totp/keycloak?secret=" + totpSecretEncoded, "utf-8");
return formsUrl + "/qrcode" + "?size=200x200&contents=" + contents;
}
private void addSocialProviders() {
// TODO Add providers configured for realm instead of all providers

View file

@ -0,0 +1,46 @@
package org.keycloak.forms;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.jboss.resteasy.logging.Logger;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
@WebServlet(urlPatterns = "/forms/qrcode")
public class QRServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private static final Logger log = Logger.getLogger(QRServlet.class);
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String[] size = req.getParameter("size").split("x");
int width = Integer.parseInt(size[0]);
int height = Integer.parseInt(size[1]);
String contents = req.getParameter("contents");
try {
QRCodeWriter writer = new QRCodeWriter();
BitMatrix bitMatrix = writer.encode(contents, BarcodeFormat.QR_CODE, width, height);
MatrixToImageWriter.writeToStream(bitMatrix, "png", resp.getOutputStream());
resp.setContentType("image/png");
} catch (Exception e) {
log.warn("Failed to generate qr code", e);
resp.sendError(500);
}
}
}

View file

@ -30,6 +30,14 @@
<input type="password" id="password-confirm" name="password-confirm" />
</div>
<h:panelGroup rendered="#{forms.totp}">
<div>
<label for="totp">#{messages.authenticatorCode}</label>
<input type="text" id="totp" name="totp" />
<input type="hidden" id="totpSecret" name="totpSecret" value="#{forms.totpSecret}" />
</div>
</h:panelGroup>
<div class="aside-btn">
<p>By registering you agree to the <a href="#">Terms of Service</a> and the <a href="#">Privacy Policy</a>.</p>
</div>
@ -40,5 +48,11 @@
<ui:define name="info">
<p>#{messages.alreadyHaveAccount} <a href="#{forms.loginUrl}">#{messages.logIn}</a>.</p>
<h:panelGroup rendered="#{not empty forms.totpSecret}">
<p>Google Authenticator setup<br/>
<img src="#{forms.totpSecretQrCodeUrl}" />
</p>
</h:panelGroup>
</ui:define>
</ui:composition>

View file

@ -29,7 +29,7 @@
<div class="feedback error bottom-left show">
<p>
<strong>#{forms.error}</strong><br/>
#{forms.errorDetails}
#{messages[forms.errorDetails]}
</p>
</div>
</h:panelGroup>

View file

@ -26,7 +26,11 @@ missingName=Please specify full name
missingEmail=Please specify email
missingUsername=Please specify username
missingPassword=Please specify password
missingTotp=Please specify authenticator code
invalidPasswordConfirm=Password confirmation doesn't match
invalidTotp=Invalid authenticator code
usernameExists=Username already exists
error=A system error has occured, contact admin

10
pom.xml
View file

@ -200,6 +200,16 @@
<artifactId>twitter4j-core</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>2.2</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>2.2</version>
</dependency>
</dependencies>
</dependencyManagement>

View file

@ -42,6 +42,12 @@ public class Messages {
public static final String MISSING_USERNAME = "missingUsername";
public static final String MISSING_TOTP = "missingTotp";
public static final String INVALID_TOTP = "invalidTotp";
public static final String USERNAME_EXISTS = "usernameExists";
public static final String ERROR = "error";
}

View file

@ -11,16 +11,20 @@ import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.models.RealmModel;
import org.keycloak.services.models.RequiredCredentialModel;
import org.keycloak.services.models.RoleModel;
import org.keycloak.services.models.UserCredentialModel;
import org.keycloak.services.models.UserModel;
import org.keycloak.services.resources.admin.RealmsAdminResource;
import org.keycloak.services.resources.flows.Flows;
import org.keycloak.services.validation.Validation;
import javax.ws.rs.*;
import javax.ws.rs.core.*;
import java.net.URI;
import java.util.LinkedList;
import java.util.List;
import java.util.StringTokenizer;
/**
@ -308,7 +312,12 @@ public class SaasService {
RealmManager realmManager = new RealmManager(session);
RealmModel defaultRealm = realmManager.defaultRealm();
String error = validateRegistrationForm(formData);
List<String> requiredCredentialTypes = new LinkedList<String>();
for (RequiredCredentialModel m : defaultRealm.getRequiredCredentials()) {
requiredCredentialTypes.add(m.getType());
}
String error = Validation.validateRegistrationForm(formData, requiredCredentialTypes);
if (error != null) {
return Flows.forms(defaultRealm, request).setError(error).setFormData(formData)
.forwardToRegistration();
@ -341,7 +350,15 @@ public class SaasService {
newUser.setFirstName(first.toString());
newUser.setLastName(last);
}
if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) {
newUser.credential(CredentialRepresentation.PASSWORD, formData.getFirst("password"));
}
if (requiredCredentialTypes.contains(CredentialRepresentation.TOTP)) {
newUser.credential(CredentialRepresentation.TOTP, formData.getFirst("password"));
}
UserModel user = registerMe(defaultRealm, newUser);
if (user == null) {
return Flows.forms(defaultRealm, request).setError(Messages.USERNAME_EXISTS)
@ -384,32 +401,4 @@ public class SaasService {
return user;
}
private String validateRegistrationForm(MultivaluedMap<String, String> formData) {
if (isEmpty(formData.getFirst("name"))) {
return Messages.MISSING_NAME;
}
if (isEmpty(formData.getFirst("email"))) {
return Messages.MISSING_EMAIL;
}
if (isEmpty(formData.getFirst("username"))) {
return Messages.MISSING_USERNAME;
}
if (isEmpty(formData.getFirst("password"))) {
return Messages.MISSING_PASSWORD;
}
if (!formData.getFirst("password").equals(formData.getFirst("password-confirm"))) {
return Messages.INVALID_PASSWORD_CONFIRM;
}
return null;
}
private boolean isEmpty(String s) {
return s == null || s.length() == 0;
}
}

View file

@ -19,11 +19,14 @@ import org.keycloak.services.managers.ResourceAdminManager;
import org.keycloak.services.managers.TokenManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.models.RealmModel;
import org.keycloak.services.models.RequiredCredentialModel;
import org.keycloak.services.models.RoleModel;
import org.keycloak.services.models.UserCredentialModel;
import org.keycloak.services.models.UserModel;
import org.keycloak.services.resources.flows.Flows;
import org.keycloak.services.resources.flows.OAuthFlows;
import org.keycloak.services.validation.Validation;
import org.picketlink.idm.credential.util.TimeBasedOTP;
import javax.ws.rs.Consumes;
import javax.ws.rs.ForbiddenException;
@ -46,6 +49,8 @@ import javax.ws.rs.ext.Providers;
import java.security.PrivateKey;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;
@ -250,7 +255,12 @@ public class TokenService {
return oauth.forwardToSecurityFailure("Registration not allowed");
}
String error = validateRegistrationForm(formData);
List<String> requiredCredentialTypes = new LinkedList<String>();
for (RequiredCredentialModel m : realm.getRequiredCredentials()) {
requiredCredentialTypes.add(m.getType());
}
String error = Validation.validateRegistrationForm(formData, requiredCredentialTypes);
if (error != null) {
return Flows.forms(realm, request).setError(error).setFormData(formData).forwardToRegistration();
}
@ -291,10 +301,19 @@ public class TokenService {
user.setEmail(formData.getFirst("email"));
if (requiredCredentialTypes.contains(CredentialRepresentation.PASSWORD)) {
UserCredentialModel credentials = new UserCredentialModel();
credentials.setType(CredentialRepresentation.PASSWORD);
credentials.setValue(formData.getFirst("password"));
realm.updateCredential(user, credentials);
}
if (requiredCredentialTypes.contains(CredentialRepresentation.TOTP)) {
UserCredentialModel credentials = new UserCredentialModel();
credentials.setType(CredentialRepresentation.TOTP);
credentials.setValue(formData.getFirst("totpSecret"));
realm.updateCredential(user, credentials);
}
for (RoleModel role : realm.getDefaultRoles()) {
realm.grantRole(user, role);
@ -577,32 +596,4 @@ public class TokenService {
return location.build();
}
private String validateRegistrationForm(MultivaluedMap<String, String> formData) {
if (isEmpty(formData.getFirst("name"))) {
return Messages.MISSING_NAME;
}
if (isEmpty(formData.getFirst("email"))) {
return Messages.MISSING_EMAIL;
}
if (isEmpty(formData.getFirst("username"))) {
return Messages.MISSING_USERNAME;
}
if (isEmpty(formData.getFirst("password"))) {
return Messages.MISSING_PASSWORD;
}
if (!formData.getFirst("password").equals(formData.getFirst("password-confirm"))) {
return Messages.INVALID_PASSWORD_CONFIRM;
}
return null;
}
private boolean isEmpty(String s) {
return s == null || s.length() == 0;
}
}

View file

@ -26,11 +26,11 @@ package org.keycloak.services.resources.flows;
*/
public class Pages {
public final static String LOGIN = "/sdk/login.xhtml";
public final static String LOGIN = "/forms/login.xhtml";
public final static String OAUTH_GRANT = "/saas/oauthGrantForm.jsp";
public final static String REGISTER = "/sdk/register.xhtml";
public final static String REGISTER = "/forms/register.xhtml";
public final static String SECURITY_FAILURE = "/saas/securityFailure.jsp";

View file

@ -0,0 +1,55 @@
package org.keycloak.services.validation;
import java.util.List;
import javax.ws.rs.core.MultivaluedMap;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.messages.Messages;
import org.picketlink.idm.credential.util.TimeBasedOTP;
public class Validation {
public static String validateRegistrationForm(MultivaluedMap<String, String> formData, List<String> requiredCredentialTypes) {
if (isEmpty(formData.getFirst("name"))) {
return Messages.MISSING_NAME;
}
if (isEmpty(formData.getFirst("email"))) {
return Messages.MISSING_EMAIL;
}
if (isEmpty(formData.getFirst("username"))) {
return Messages.MISSING_USERNAME;
}
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 (requiredCredentialTypes.contains(CredentialRepresentation.TOTP)) {
if (isEmpty(formData.getFirst("totp"))) {
return Messages.MISSING_TOTP;
}
boolean validTotp = new TimeBasedOTP().validate(formData.getFirst("totp"), formData.getFirst("totpSecret")
.getBytes());
if (!validTotp) {
return Messages.INVALID_TOTP;
}
}
return null;
}
public static boolean isEmpty(String s) {
return s == null || s.length() == 0;
}
}