Merge pull request #67 from vrockai/KEYCLOAK-84

KEYCLOAK-84 form for managing totp authentificators
This commit is contained in:
stianst 2013-10-15 02:26:27 -07:00
commit 5728a00cd4
14 changed files with 142 additions and 21 deletions

View file

@ -120,6 +120,10 @@ public class UrlBean {
return Urls.accountTotpPage(baseURI, realm.getId()).toString(); return Urls.accountTotpPage(baseURI, realm.getId()).toString();
} }
public String getTotpRemoveUrl() {
return Urls.accountTotpRemove(baseURI, realm.getId()).toString();
}
public String getEmailVerificationUrl() { public String getEmailVerificationUrl() {
return Urls.accountEmailVerification(baseURI, realm.getId()).toString(); return Urls.accountEmailVerification(baseURI, realm.getId()).toString();
} }

View file

@ -81,6 +81,10 @@ public class FormServiceImpl implements FormService {
Map<String, Object> attributes = new HashMap<String, Object>(); Map<String, Object> attributes = new HashMap<String, Object>();
if (dataBean.getError() != null){
attributes.put("message", new ErrorBean(dataBean.getError(), dataBean.getErrorType()));
}
RealmBean realm = new RealmBean(dataBean.getRealm()); RealmBean realm = new RealmBean(dataBean.getRealm());
attributes.put("template", new TemplateBean(realm, dataBean.getContextPath())); attributes.put("template", new TemplateBean(realm, dataBean.getContextPath()));
@ -145,10 +149,6 @@ public class FormServiceImpl implements FormService {
private class CommandPassword implements Command { private class CommandPassword implements Command {
public void exec(Map<String, Object> attributes, FormServiceDataBean dataBean) { public void exec(Map<String, Object> attributes, FormServiceDataBean dataBean) {
if (dataBean.getError() != null){
attributes.put("message", new ErrorBean(dataBean.getError(), dataBean.getErrorType()));
}
RealmBean realm = new RealmBean(dataBean.getRealm()); RealmBean realm = new RealmBean(dataBean.getRealm());
attributes.put("realm", realm); attributes.put("realm", realm);

View file

@ -60,6 +60,7 @@ body {
} }
.header.rcue .navbar { .header.rcue .navbar {
margin-bottom: 0; margin-bottom: 0;
z-index:auto;
} }
.header.rcue .navbar.primary { .header.rcue .navbar.primary {
font-size: 13px; font-size: 13px;

View file

@ -25,7 +25,7 @@
<!-- <p>Forgot <a href="#">Username</a> or <a href="#">Password</a>?</p> --> <!-- <p>Forgot <a href="#">Username</a> or <a href="#">Password</a>?</p> -->
</div> </div>
<input type="submit" value="Log In" /> <input class="btn-primary" type="submit" value="Log In" />
</form> </form>
<#elseif section = "info"> <#elseif section = "info">

View file

@ -35,7 +35,8 @@
</div> </div>
<#if error?has_content> <#if error?has_content>
<div class="feedback error bottom-left show"> <div class="
error bottom-left show">
<p> <p>
<strong id="loginError">${rb.getString(error.summary)}</strong> <strong id="loginError">${rb.getString(error.summary)}</strong>
</p> </p>

View file

@ -3,7 +3,7 @@
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Edit Account</title> <title>Edit Account - <#nested "title"></title>
<link rel="icon" href="img/favicon.ico"> <link rel="icon" href="img/favicon.ico">
<!-- Frameworks --> <!-- Frameworks -->
@ -34,10 +34,14 @@
</head> </head>
<body class="admin-console user ${bodyClass}"> <body class="admin-console user ${bodyClass}">
<#if error?has_content> <#if message?has_content>
<!--div class="feedback success show"><p><strong>Success!</strong> Your changes have been saved.</p></div-->
<div class="feedback-aligner"> <div class="feedback-aligner">
<div class="alert alert-danger">${rb.getString(error.summary)}</div> <#if message.success>
<div class="feedback success show"><p><strong>${rb.getString('successHeader')}</strong> ${rb.getString(message.summary)}</p></div>
</#if>
<#if message.error>
<div class="feedback error show"><p><strong>${rb.getString('errorHeader')}</strong> ${rb.getString(message.summary)}</p></div>
</#if>
</div> </div>
</#if> </#if>

View file

@ -1,7 +1,9 @@
<#import "template-main.ftl" as layout> <#import "template-main.ftl" as layout>
<@layout.mainLayout active='totp' bodyClass='totp'; section> <@layout.mainLayout active='totp' bodyClass='totp'; section>
<#if section = "header"> <#if section = "title">
Google Authenticator
<#elseif section = "header">
<#if totp.enabled> <#if totp.enabled>
<h2>Authenticators</h2> <h2>Authenticators</h2>
@ -12,7 +14,6 @@
<#elseif section = "content"> <#elseif section = "content">
<#if totp.enabled> <#if totp.enabled>
<#-- TODO this is only mock page -->
<form> <form>
<fieldset> <fieldset>
<p class="info">You have the following authenticators set up:</p> <p class="info">You have the following authenticators set up:</p>
@ -21,11 +22,16 @@
<tbody> <tbody>
<tr> <tr>
<td class="provider"><span class="social googleplus">Google</span></td> <td class="provider"><span class="social googleplus">Google</span></td>
<td class="soft">Connected as john@google.com</td> <td class="action">
<td class="action"><a href="user-totp-setup.html" class="button">Remove Google</a></td> <a href="${url.totpRemoveUrl}" class="button">Remove Google</a>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<p class="info">
If the totp authentication is required by the realm and you remove your configured authenticator,
you will have to reconfigure it immediately or on the next login.
</p>
</fieldset> </fieldset>
</form> </form>

View file

@ -37,6 +37,9 @@ invalidPasswordExisting=Invalid existing password
invalidPasswordConfirm=Password confirmation doesn't match invalidPasswordConfirm=Password confirmation doesn't match
invalidTotp=Invalid authenticator code invalidTotp=Invalid authenticator code
successTotp=Google authenticator configured.
successTotpRemoved=Google authenticator removed.
usernameExists=Username already exists usernameExists=Username already exists
error=A system error has occured, contact admin error=A system error has occured, contact admin

View file

@ -164,6 +164,15 @@ public class AccountService {
return accessCodeEntry; return accessCodeEntry;
} }
@Path("totp-remove")
@GET
public Response processTotpRemove() {
UserModel user = getUserFromAuthManager();
user.setTotp(false);
return Flows.forms(realm, request, uriInfo).setError("successTotpRemoved").setErrorType(FormFlows.ErrorType.SUCCESS)
.setUser(user).forwardToTotp();
}
@Path("totp") @Path("totp")
@POST @POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@ -206,7 +215,8 @@ public class AccountService {
if (accessCodeEntry != null) { if (accessCodeEntry != null) {
return redirectOauth(user, accessCodeEntry); return redirectOauth(user, accessCodeEntry);
} else { } else {
return Flows.forms(realm, request, uriInfo).setUser(user).forwardToTotp(); return Flows.forms(realm, request, uriInfo).setError("successTotp").setErrorType(FormFlows.ErrorType.SUCCESS)
.setUser(user).forwardToTotp();
} }
} }

View file

@ -55,6 +55,10 @@ public class Urls {
return accountBase(baseUri).path(AccountService.class, "totpPage").build(realmId); return accountBase(baseUri).path(AccountService.class, "totpPage").build(realmId);
} }
public static URI accountTotpRemove(URI baseUri, String realmId) {
return accountBase(baseUri).path(AccountService.class, "processTotpRemove").build(realmId);
}
public static URI accountEmailVerification(URI baseUri, String realmId) { public static URI accountEmailVerification(URI baseUri, String realmId) {
return accountBase(baseUri).path(AccountService.class, "emailVerification").build(realmId); return accountBase(baseUri).path(AccountService.class, "emailVerification").build(realmId);
} }

View file

@ -30,6 +30,8 @@ import org.keycloak.services.managers.RealmManager;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserModel.RequiredAction; import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.testsuite.OAuthClient;
import org.keycloak.testsuite.pages.AccountTotpPage;
import org.keycloak.testsuite.pages.AppPage; import org.keycloak.testsuite.pages.AppPage;
import org.keycloak.testsuite.pages.AppPage.RequestType; import org.keycloak.testsuite.pages.AppPage.RequestType;
import org.keycloak.testsuite.pages.LoginConfigTotpPage; import org.keycloak.testsuite.pages.LoginConfigTotpPage;
@ -54,9 +56,6 @@ public class RequiredActionTotpSetupTest {
public void config(RealmManager manager, RealmModel defaultRealm, RealmModel appRealm) { public void config(RealmManager manager, RealmModel defaultRealm, RealmModel appRealm) {
appRealm.addRequiredCredential(CredentialRepresentation.TOTP); appRealm.addRequiredCredential(CredentialRepresentation.TOTP);
appRealm.setResetPasswordAllowed(true); appRealm.setResetPasswordAllowed(true);
UserModel user = appRealm.getUser("test-user@localhost");
user.addRequiredAction(RequiredAction.CONFIGURE_TOTP);
} }
}); });
@ -76,6 +75,12 @@ public class RequiredActionTotpSetupTest {
@WebResource @WebResource
protected LoginConfigTotpPage totpPage; protected LoginConfigTotpPage totpPage;
@WebResource
protected AccountTotpPage accountTotpPage;
@WebResource
protected OAuthClient oauth;
@WebResource @WebResource
protected RegisterPage registerPage; protected RegisterPage registerPage;
@ -101,9 +106,69 @@ public class RequiredActionTotpSetupTest {
totpPage.assertCurrent(); totpPage.assertCurrent();
totpPage.configure(totp.generate(totpPage.getTotpSecret())); String totpSecret = totpPage.getTotpSecret();
totpPage.configure(totp.generate(totpSecret));
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
oauth.openLogout();
loginPage.open();
loginPage.loginTotp("test-user@localhost", "password", totp.generate(totpSecret));
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType()); Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
} }
@Test
public void setupTotpRegisteredAfterTotpRemoval() {
// Register new user
loginPage.open();
loginPage.clickRegister();
registerPage.register("firstName2", "lastName2", "email2", "setupTotp2", "password2", "password2");
// Configure totp
totpPage.assertCurrent();
String totpCode = totpPage.getTotpSecret();
totpPage.configure(totp.generate(totpCode));
// After totp config, user should be on the app page
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
// Logout
oauth.openLogout();
// Try to login after logout
loginPage.open();
loginPage.login("setupTotp2", "password2");
// Totp is already configured, thus one-time password is needed, login page should be loaded
Assert.assertTrue(loginPage.isCurrent());
Assert.assertFalse(totpPage.isCurrent());
// Login with one-time password
loginPage.loginTotp("setupTotp2", "password2", totp.generate(totpCode));
// Open account page
accountTotpPage.open();
accountTotpPage.assertCurrent();
// Remove google authentificator
accountTotpPage.removeTotp();
// Logout
oauth.openLogout();
// Try to login
loginPage.open();
loginPage.login("setupTotp2", "password2");
// Since the authentificator was removed, it has to be set up again
totpPage.assertCurrent();
totpPage.configure(totp.generate(totpPage.getTotpSecret()));
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
}
} }

View file

@ -41,6 +41,9 @@ public class AccountTotpPage extends Page {
@FindBy(css = "button[type=\"submit\"]") @FindBy(css = "button[type=\"submit\"]")
private WebElement submitButton; private WebElement submitButton;
@FindBy(linkText = "Remove Google")
private WebElement removeLink;
public void configure(String totp) { public void configure(String totp) {
totpInput.sendKeys(totp); totpInput.sendKeys(totp);
submitButton.click(); submitButton.click();
@ -51,11 +54,15 @@ public class AccountTotpPage extends Page {
} }
public boolean isCurrent() { public boolean isCurrent() {
return driver.getPageSource().contains("Google Authenticator Setup"); return driver.getTitle().contains("Edit Account - Google Authenticator");
} }
public void open() { public void open() {
driver.navigate().to(PATH); driver.navigate().to(PATH);
} }
public void removeTotp() {
removeLink.click();
}
} }

View file

@ -41,6 +41,9 @@ public class LoginPage extends Page {
@FindBy(id = "password") @FindBy(id = "password")
private WebElement passwordInput; private WebElement passwordInput;
@FindBy(id = "totp")
private WebElement totp;
@FindBy(css = "input[type=\"submit\"]") @FindBy(css = "input[type=\"submit\"]")
private WebElement submitButton; private WebElement submitButton;
@ -63,6 +66,19 @@ public class LoginPage extends Page {
submitButton.click(); submitButton.click();
} }
public void loginTotp(String username, String password, String code) {
usernameInput.clear();
usernameInput.sendKeys(username);
passwordInput.clear();
passwordInput.sendKeys(password);
totp.clear();
totp.sendKeys(code);
submitButton.click();
}
public String getError() { public String getError() {
return loginErrorMessage != null ? loginErrorMessage.getText() : null; return loginErrorMessage != null ? loginErrorMessage.getText() : null;
} }

View file

@ -35,7 +35,7 @@ public abstract class Page {
public void assertCurrent() { public void assertCurrent() {
String name = getClass().getSimpleName(); String name = getClass().getSimpleName();
Assert.assertTrue("Exptected " + name + " but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")", Assert.assertTrue("Expected " + name + " but was " + driver.getTitle() + " (" + driver.getCurrentUrl() + ")",
isCurrent()); isCurrent());
} }