Merge pull request #1541 from patriot1burke/master

reset password refactor
This commit is contained in:
Bill Burke 2015-08-16 17:26:49 -04:00
commit 8c0b33f902
56 changed files with 1187 additions and 232 deletions

View file

@ -52,6 +52,9 @@
<column name="DIRECT_GRANT_FLOW" type="VARCHAR(36)">
<constraints nullable="true"/>
</column>
<column name="RESET_CREDENTIALS_FLOW" type="VARCHAR(36)">
<constraints nullable="true"/>
</column>
</addColumn>
</changeSet>

View file

@ -19,9 +19,12 @@ public class HmacTest {
@Test
public void testHmacSignatures() throws Exception {
SecretKey secret = new SecretKeySpec(UUID.randomUUID().toString().getBytes(), "HmacSHA256");
String encoded = new JWSBuilder().content("hello world".getBytes())
String encoded = new JWSBuilder().content("12345678901234567890".getBytes())
.hmac256(secret);
System.out.println("length: " + encoded.length());
JWSInput input = new JWSInput(encoded);
Assert.assertTrue(HMACProvider.verify(input, secret));
}
}

View file

@ -363,7 +363,7 @@ module.controller('UserDetailCtrl', function($scope, realm, user, BruteForceUser
}
}
// ID - Name map for required actions. IDs are enum names.
RequiredActions.query({id: realm.realm}, function(data) {
RequiredActions.query({realm: realm.realm}, function(data) {
$scope.userReqActionList = [];
for (var i = 0; i < data.length; i++) {
console.log("listed required action: " + data[i].name);

View file

@ -35,6 +35,17 @@
<kc-tooltip>Select the flow you want to use for direct grant authentication.</kc-tooltip>
</div>
<div class="form-group">
<label for="resetCredentials" class="col-md-2 control-label">Reset Credentials</label>
<div class="col-md-2">
<div>
<select id="resetCredentials" ng-model="realm.resetCredentialsFlow" class="form-control" ng-options="flow.alias as flow.alias for flow in flows">
</select>
</div>
</div>
<kc-tooltip>Select the flow you want to use when the user has forgotten their credentials.</kc-tooltip>
</div>
<div class="form-group" data-ng-show="access.manageRealm">
<div class="col-md-12">
<button kc-save data-ng-disabled="!changed">Save</button>

View file

@ -5,7 +5,7 @@
<#elseif section = "header">
${msg("emailForgotTitle")}
<#elseif section = "form">
<form id="kc-reset-password-form" class="${properties.kcFormClass!}" action="${url.loginPasswordResetUrl}" method="post">
<form id="kc-reset-password-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="username" class="${properties.kcLabelClass!}">${msg("usernameOrEmail")}</label>

View file

@ -42,7 +42,7 @@
</#if>
<div class="${properties.kcFormOptionsWrapperClass!}">
<#if realm.resetPasswordAllowed>
<span><a href="${url.loginPasswordResetUrl}">${msg("doForgotPassword")}</a></span>
<span><a href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
</#if>
</div>
</div>

View file

@ -80,7 +80,9 @@ emailVerifyInstruction3=um ein neues E-Mail zu verschicken.
backToLogin=&laquo; Zur\u00FCck zur Anmeldung
backToApplication=&laquo; Zur\u00FCck zur Applikation
temporaryEmailCode=Temporary Email Code
emailInstruction=Geben Sie ihren Benutzernamen oder E-Mail Adresse ein und klicken Sie auf Absenden. Danach werden wir ihnen ein E-Mail mit weiteren Instruktionen zusenden.
validateResetEmailInstruction=You have just been sent an email. Clicking on the URL in the email will allow you to reset credentials and log in. Alternatively, you can manually enter in the temporary code provided in the email in the textbox to the left and hit submit.
copyCodeInstruction=Bitte kopieren sie den folgenden Code und f\u00FCgen ihn in die Applikation ein\:

View file

@ -81,7 +81,9 @@ emailVerifyInstruction3=to re-send the email.
backToLogin=&laquo; Back to Login
temporaryEmailCode=Temporary Email Code
emailInstruction=Enter your username or email address and we will send you instructions on how to create a new password.
validateResetEmailInstruction=You have just been sent an email. Clicking on the URL in the email will allow you to reset credentials and log in. Alternatively, you can manually enter in the temporary code provided in the email in the textbox to the left and hit submit.
copyCodeInstruction=Please copy this code and paste it into your application:

View file

@ -77,7 +77,9 @@ emailVerifyInstruction3=per reinviare la mail.
backToLogin=&laquo; Torna al Login
temporaryEmailCode=Temporary Email Code
emailInstruction=Scrivi il tuo username o indirizzo email e noi ti invieremo le istruzioni per creare una nuova password.
validateResetEmailInstruction=You have just been sent an email. Clicking on the URL in the email will allow you to reset credentials and log in. Alternatively, you can manually enter in the temporary code provided in the email in the textbox to the left and hit submit.
copyCodeInstruction=Copiaquesto codice e incollalo nella tua applicazione:

View file

@ -77,7 +77,9 @@ emailVerifyInstruction3=para reenviar o e-mail.
backToLogin=&laquo; Voltar
temporaryEmailCode=Temporary Email Code
emailInstruction=Digite seu nome de usu\u00E1rio ou endere\u00E7o de email e n\u00F3s lhe enviaremos instru\u00E7\u00F5es sobre como criar uma nova senha.
validateResetEmailInstruction=You have just been sent an email. Clicking on the URL in the email will allow you to reset credentials and log in. Alternatively, you can manually enter in the temporary code provided in the email in the textbox to the left and hit submit.
copyCodeInstruction=Por favor, copie o c\u00F3digo e cole-o em sua aplica\u00E7\u00E3o:

View file

@ -0,0 +1,31 @@
<#import "template.ftl" as layout>
<@layout.registrationLayout displayInfo=true; section>
<#if section = "title">
${msg("emailForgotTitle")}
<#elseif section = "header">
${msg("emailForgotTitle")}
<#elseif section = "form">
<form id="kc-reset-password-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="otp" class="${properties.kcLabelClass!}">${msg("temporaryEmailCode")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input type="text" id="key" name="key" class="${properties.kcInputClass!}" />
</div>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-options" class="${properties.kcFormOptionsClass!}">
</div>
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<input class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-submit" type="submit" value="${msg("doSubmit")}"/>
<input class="${properties.kcButtonClass!} ${properties.kcButtonDefaultClass!} ${properties.kcButtonLargeClass!}" name="cancel" id="kc-cancel" type="submit" value="${msg("backToLogin")}"/>
</div>
</div>
</form>
<#elseif section = "info" >
${msg("validateResetEmailInstruction")}
</#if>
</@layout.registrationLayout>

View file

@ -0,0 +1,5 @@
<html>
<body>
${msg("changePasswordBodyHtml",link, linkExpiration, realmName)}
</body>
</html>

View file

@ -1,5 +1,5 @@
<html>
<body>
${msg("passwordResetBodyHtml",link, linkExpiration, realmName)}
${msg("passwordResetBodyHtml",link, linkExpiration, realmName, code)}
</body>
</html>

View file

@ -1,7 +1,10 @@
emailVerificationSubject=E-Mail verifizieren
passwordResetSubject=Passwort zur\u00FCckzusetzen
passwordResetBody=Jemand hat angefordert Ihr {2} Passwort zur\u00FCckzusetzen. Falls das Sie waren, dann klicken Sie auf den folgenden Link um das Passwort zur\u00FCckzusetzen.\n\n{0}\n\nDieser Link wird in {1} Minuten ablaufen.\n\nFalls Sie das Passwort nicht zur\u00FCcksetzen m\u00F6chten, dann k\u00F6nnen Sie diese E-Mail ignorieren.
passwordResetBodyHtml=<p>Jemand hat angefordert Ihr {2} Passwort zur\u00FCckzusetzen. Falls das Sie waren, dann klicken Sie auf den folgenden Link um das Passwort zur\u00FCckzusetzen.</p><p><a href="{0}">{0}</a></p><p>Dieser Link wird in {1} Minuten ablaufen.</p><p>Falls Sie das Passwort nicht zur\u00FCcksetzen m\u00F6chten, dann k\u00F6nnen Sie diese E-Mail ignorieren.</p>
passwordResetBody=Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password or cut and paste the temporary code to the forgot password form.\n\n{0}\n\nTemporary Code: {3}\n\nThis link and code will expire within {1} minutes.\n\nIf you don''t want to reset your password, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password or cut and paste the temporary code to the forgot password form</p><p><a href="{0}">{0}</a></p><p>Temporary code: {3}<p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your password, just ignore this message and nothing will be changed.</p>
changePasswordSubject=Change password
changePasswordBody=Your adminstrator has just requested that you change your {2} account''s password. Click on the link below to set a new password\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you don''t want to reset your password, just ignore this message and nothing will be changed.
changePasswordBodyHtml=<p>Your adminstrator has just requested that you change your {2} account''s password. Click on the link below to set a new password</p><p><a href="{0}">{0}</a></p><p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your password, just ignore this message and nothing will be changed.</p>
emailVerificationBody=Jemand hat ein {2} Konto mit dieser E-Mail Adresse erstellt. Fall das Sie waren, dann klicken Sie auf den Link um die E-Mail Adresse zu verifizieren.\n\n{0}\n\nDieser Link wird in {1} Minuten ablaufen.\n\nFalls Sie dieses Konto nicht erstellt haben, dann k\u00F6nnen sie diese Nachricht ignorieren.
emailVerificationBodyHtml=<p>Jemand hat ein {2} Konto mit dieser E-Mail Adresse erstellt. Fall das Sie waren, dann klicken Sie auf den Link um die E-Mail Adresse zu verifizieren.</p><p><a href="{0}">{0}</a></p><p>Dieser Link wird in {1} Minuten ablaufen.</p><p>Falls Sie dieses Konto nicht erstellt haben, dann k\u00F6nnen sie diese Nachricht ignorieren.</p>
eventLoginErrorSubject=Fehlgeschlagene Anmeldung

View file

@ -2,8 +2,11 @@ emailVerificationSubject=Verify email
emailVerificationBody=Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you didn''t create this account, just ignore this message.
emailVerificationBodyHtml=<p>Someone has created a {2} account with this email address. If this was you, click the link below to verify your email address</p><p><a href="{0}">{0}</a></p><p>This link will expire within {1} minutes.</p><p>If you didn''t create this account, just ignore this message.</p>
passwordResetSubject=Reset password
passwordResetBody=Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you don''t want to reset your password, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password</p><p><a href="{0}">{0}</a></p><p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your password, just ignore this message and nothing will be changed.</p>
passwordResetBody=Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password or cut and paste the temporary code to the forgot password form.\n\n{0}\n\nTemporary Code: {3}\n\nThis link and code will expire within {1} minutes.\n\nIf you don''t want to reset your password, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password or cut and paste the temporary code to the forgot password form</p><p><a href="{0}">{0}</a></p><p>Temporary code: {3}<p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your password, just ignore this message and nothing will be changed.</p>
changePasswordSubject=Change password
changePasswordBody=Your adminstrator has just requested that you change your {2} account''s password. Click on the link below to set a new password\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you don''t want to reset your password, just ignore this message and nothing will be changed.
changePasswordBodyHtml=<p>Your adminstrator has just requested that you change your {2} account''s password. Click on the link below to set a new password</p><p><a href="{0}">{0}</a></p><p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your password, just ignore this message and nothing will be changed.</p>
eventLoginErrorSubject=Login error
eventLoginErrorBody=A failed login attempt was detected to your account on {0} from {1}. If this was not you, please contact an admin.
eventLoginErrorBodyHtml=<p>A failed login attempt was detected to your account on {0} from {1}. If this was not you, please contact an admin.</p>

View file

@ -2,8 +2,11 @@ emailVerificationSubject=Verifica\u00E7\u00E3o de e-mail
emailVerificationBody=Algu\u00E9m criou uma conta {2} com este endere\u00E7o de e-mail. Se foi voc\u00EA, clique no link abaixo para verificar o seu endere\u00E7o de email\n\n{0}\n\nEste link ir\u00E1 expirar dentro de {1} minutos.\n\nSe n\u00E3o foi voc\u00EA que criou esta conta, basta ignorar esta mensagem.
emailVerificationBodyHtml=<p>Algu\u00E9m criou uma conta {2} com este endere\u00E7o de e-mail. Se foi voc\u00EA, clique no link abaixo para verificar o seu endere\u00E7o de email</p><p><a href="{0}">{0}</a></p><p>Este link ir\u00E1 expirar dentro de {1} minutos.</p><p>Se n\u00E3o foi voc\u00EA que criou esta conta, basta ignorar esta mensagem.</p>
passwordResetSubject=Redefini\u00E7\u00E3o de senha
passwordResetBody=Algu\u00E9m pediu para mudar a senha de sua conta {2}. Se foi voc\u00EA, clique no link abaixo para definir uma nova senha\n\n{0}\n\nEste link ir\u00E1 expirar dentro de {1} minutos.\n\nSe voc\u00EA n\u00E3o deseja redefinir sua senha, basta ignorar esta mensagem e nada ser\u00E1 mudado.
passwordResetBodyHtml=<p>Algu\u00E9m pediu para mudar a senha de sua conta {2}. Se foi voc\u00EA, clique no link abaixo para definir uma nova senha</p><p><a href="{0}">{0}</a></p><p>Este link ir\u00E1 expirar dentro de {1} minutos.</p><p>Se voc\u00EA n\u00E3o deseja redefinir sua senha, basta ignorar esta mensagem e nada ser\u00E1 mudado.</p>
passwordResetBody=Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password or cut and paste the temporary code to the forgot password form.\n\n{0}\n\nTemporary Code: {3}\n\nThis link and code will expire within {1} minutes.\n\nIf you don''t want to reset your password, just ignore this message and nothing will be changed.
passwordResetBodyHtml=<p>Someone just requested to change your {2} account''s password. If this was you, click on the link below to set a new password or cut and paste the temporary code to the forgot password form</p><p><a href="{0}">{0}</a></p><p>Temporary code: {3}<p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your password, just ignore this message and nothing will be changed.</p>
changePasswordSubject=Change password
changePasswordBody=Your adminstrator has just requested that you change your {2} account''s password. Click on the link below to set a new password\n\n{0}\n\nThis link will expire within {1} minutes.\n\nIf you don''t want to reset your password, just ignore this message and nothing will be changed.
changePasswordBodyHtml=<p>Your adminstrator has just requested that you change your {2} account''s password. Click on the link below to set a new password</p><p><a href="{0}">{0}</a></p><p>This link will expire within {1} minutes.</p><p>If you don''t want to reset your password, just ignore this message and nothing will be changed.</p>
eventLoginErrorSubject=Erro de login
eventLoginErrorBody=Uma tentativa de login mal sucedida para a sua conta foi detectada em {0} de {1}. Se n\u00E3o foi voc\u00EA, por favor, entre em contato com um administrador.
eventLoginErrorBodyHtml=<p>Uma tentativa de login mal sucedida para a sua conta foi detectada em {0} de {1}. Se n\u00E3o foi voc\u00EA, por favor, entre em contato com um administrador.</p>

View file

@ -0,0 +1 @@
${msg("changePasswordBody",link, linkExpiration, realmName)}

View file

@ -1 +1 @@
${msg("passwordResetBody",link, linkExpiration, realmName)}
${msg("passwordResetBody",link, linkExpiration, realmName, code)}

View file

@ -16,7 +16,24 @@ public interface EmailProvider extends Provider {
public void sendEvent(Event event) throws EmailException;
public void sendPasswordReset(String link, long expirationInMinutes) throws EmailException;
/**
* Reset password sent from forgot password link on login
*
* @param code
* @param link
* @param expirationInMinutes
* @throws EmailException
*/
public void sendPasswordReset(String code, String link, long expirationInMinutes) throws EmailException;
/**
* Change password email requested by admin
*
* @param link
* @param expirationInMinutes
* @throws EmailException
*/
public void sendChangePassword(String link, long expirationInMinutes) throws EmailException;
public void sendVerifyEmail(String link, long expirationInMinutes) throws EmailException;

View file

@ -70,7 +70,20 @@ public class FreeMarkerEmailProvider implements EmailProvider {
}
@Override
public void sendPasswordReset(String link, long expirationInMinutes) throws EmailException {
public void sendPasswordReset(String code, String link, long expirationInMinutes) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>();
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
attributes.put("code", code);
String realmName = realm.getName().substring(0, 1).toUpperCase() + realm.getName().substring(1);
attributes.put("realmName", realmName);
send("passwordResetSubject", "password-reset.ftl", attributes);
}
@Override
public void sendChangePassword(String link, long expirationInMinutes) throws EmailException {
Map<String, Object> attributes = new HashMap<String, Object>();
attributes.put("link", link);
attributes.put("linkExpiration", expirationInMinutes);
@ -78,7 +91,8 @@ public class FreeMarkerEmailProvider implements EmailProvider {
String realmName = realm.getName().substring(0, 1).toUpperCase() + realm.getName().substring(1);
attributes.put("realmName", realmName);
send("passwordResetSubject", "password-reset.ftl", attributes);
send("changePasswordSubject", "changePassword.ftl", attributes);
}
@Override

View file

@ -78,8 +78,8 @@ public class UrlBean {
return Urls.loginActionUpdateProfile(baseURI, realm).toString();
}
public String getLoginPasswordResetUrl() {
return Urls.loginPasswordReset(baseURI, realm).toString();
public String getLoginResetCredentialsUrl() {
return Urls.loginResetCredentials(baseURI, realm).toString();
}
public String getLoginUsernameReminderUrl() {

View file

@ -45,7 +45,7 @@ public class MigrationModelManager {
if (stored != null) {
logger.debug("Migrating older model to 1.5.0 updates");
}
new MigrateTo1_4_0().migrate(session);
new MigrateTo1_5_0().migrate(session);
}
model.setStoredVersion(MigrationModel.LATEST_VERSION);

View file

@ -24,10 +24,12 @@ public class MigrateTo1_5_0 {
public void migrate(KeycloakSession session) {
List<RealmModel> realms = session.realms().getRealms();
for (RealmModel realm : realms) {
DefaultAuthenticationFlows.migrateFlows(realm); // add reset credentials flo
realm.setOTPPolicy(OTPPolicy.DEFAULT_POLICY);
realm.setBrowserFlow(realm.getFlowByAlias(DefaultAuthenticationFlows.BROWSER_FLOW));
realm.setRegistrationFlow(realm.getFlowByAlias(DefaultAuthenticationFlows.REGISTRATION_FLOW));
realm.setDirectGrantFlow(realm.getFlowByAlias(DefaultAuthenticationFlows.DIRECT_GRANT_FLOW));
realm.setResetCredentialsFlow(realm.getFlowByAlias(DefaultAuthenticationFlows.RESET_CREDENTIALS_FLOW));
}
}

View file

@ -80,7 +80,8 @@ public interface ClientSessionModel {
RECOVER_PASSWORD,
AUTHENTICATE,
SOCIAL_CALLBACK,
LOGGED_OUT
LOGGED_OUT,
RESET_CREDENTIALS
}
public enum ExecutionStatus {

View file

@ -191,6 +191,9 @@ public interface RealmModel extends RoleContainerModel {
AuthenticationFlowModel getDirectGrantFlow();
void setDirectGrantFlow(AuthenticationFlowModel flow);
AuthenticationFlowModel getResetCredentialsFlow();
void setResetCredentialsFlow(AuthenticationFlowModel flow);
List<AuthenticationFlowModel> getAuthenticationFlows();
AuthenticationFlowModel getFlowByAlias(String alias);
AuthenticationFlowModel addAuthenticationFlow(AuthenticationFlowModel model);

View file

@ -89,6 +89,7 @@ public class RealmEntity extends AbstractIdentifiableEntity {
private String browserFlow;
private String registrationFlow;
private String directGrantFlow;
private String resetCredentialsFlow;
public String getName() {
@ -593,6 +594,14 @@ public class RealmEntity extends AbstractIdentifiableEntity {
public void setDirectGrantFlow(String directGrantFlow) {
this.directGrantFlow = directGrantFlow;
}
public String getResetCredentialsFlow() {
return resetCredentialsFlow;
}
public void setResetCredentialsFlow(String resetCredentialsFlow) {
this.resetCredentialsFlow = resetCredentialsFlow;
}
}

View file

@ -15,22 +15,22 @@ public class DefaultAuthenticationFlows {
public static final String REGISTRATION_FORM_FLOW = "registration form";
public static final String BROWSER_FLOW = "browser";
public static final String DIRECT_GRANT_FLOW = "direct grant";
public static final String RESET_CREDENTIALS_FLOW = "reset credentials";
public static final String LOGIN_FORMS_FLOW = "forms";
public static void addFlows(RealmModel realm) {
if (realm.getFlowByAlias(BROWSER_FLOW) == null) browserFlow(realm);
if (realm.getFlowByAlias(DIRECT_GRANT_FLOW) == null) directGrantFlow(realm, false);
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm);
if (realm.getFlowByAlias(RESET_CREDENTIALS_FLOW) == null) resetCredentialsFlow(realm);
}
public static void migrateFlows(RealmModel realm) {
browserFlow(realm, true);
directGrantFlow(realm, true);
if (realm.getFlowByAlias(BROWSER_FLOW) == null) browserFlow(realm, true);
if (realm.getFlowByAlias(DIRECT_GRANT_FLOW) == null) directGrantFlow(realm, true);
if (realm.getFlowByAlias(REGISTRATION_FLOW) == null) registrationFlow(realm);
if (realm.getFlowByAlias(RESET_CREDENTIALS_FLOW) == null) resetCredentialsFlow(realm);
}
public static void registrationFlow(RealmModel realm) {
AuthenticationFlowModel registrationFlow = new AuthenticationFlowModel();
registrationFlow.setAlias(REGISTRATION_FLOW);
@ -118,6 +118,53 @@ public class DefaultAuthenticationFlows {
return false;
}
public static void resetCredentialsFlow(RealmModel realm) {
AuthenticationFlowModel grant = new AuthenticationFlowModel();
grant.setAlias(RESET_CREDENTIALS_FLOW);
grant.setDescription("Reset credentials for a user if they forgot their password or something");
grant.setProviderId("basic-flow");
grant.setTopLevel(true);
grant.setBuiltIn(true);
grant = realm.addAuthenticationFlow(grant);
realm.setResetCredentialsFlow(grant);
// username
AuthenticationExecutionModel execution = new AuthenticationExecutionModel();
execution.setParentFlow(grant.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator("reset-credentials-choose-user");
execution.setPriority(10);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
// send email
execution = new AuthenticationExecutionModel();
execution.setParentFlow(grant.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator("reset-credential-email");
execution.setPriority(20);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
// password
execution = new AuthenticationExecutionModel();
execution.setParentFlow(grant.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.REQUIRED);
execution.setAuthenticator("reset-password");
execution.setPriority(30);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
// otp
execution = new AuthenticationExecutionModel();
execution.setParentFlow(grant.getId());
execution.setRequirement(AuthenticationExecutionModel.Requirement.OPTIONAL);
execution.setAuthenticator("reset-otp");
execution.setPriority(40);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
}
public static void directGrantFlow(RealmModel realm, boolean migrate) {
AuthenticationFlowModel grant = new AuthenticationFlowModel();
grant.setAlias(DIRECT_GRANT_FLOW);
@ -160,9 +207,6 @@ public class DefaultAuthenticationFlows {
execution.setPriority(30);
execution.setAuthenticatorFlow(false);
realm.addAuthenticatorExecution(execution);
}
public static void browserFlow(RealmModel realm, boolean migrate) {

View file

@ -1269,6 +1269,19 @@ public class RealmAdapter implements RealmModel {
}
@Override
public AuthenticationFlowModel getResetCredentialsFlow() {
String flowId = realm.getResetCredentialsFlow();
if (flowId == null) return null;
return getAuthenticationFlowById(flowId);
}
@Override
public void setResetCredentialsFlow(AuthenticationFlowModel flow) {
realm.setResetCredentialsFlow(flow.getId());
}
@Override
public List<AuthenticationFlowModel> getAuthenticationFlows() {

View file

@ -1051,6 +1051,18 @@ public class RealmAdapter implements RealmModel {
getDelegateForUpdate();
updated.setDirectGrantFlow(flow);
}
@Override
public AuthenticationFlowModel getResetCredentialsFlow() {
if (updated != null) return updated.getResetCredentialsFlow();
return cached.getResetCredentialsFlow();
}
@Override
public void setResetCredentialsFlow(AuthenticationFlowModel flow) {
getDelegateForUpdate();
updated.setResetCredentialsFlow(flow);
}
@Override

View file

@ -94,6 +94,7 @@ public class CachedRealm implements Serializable {
private AuthenticationFlowModel browserFlow;
private AuthenticationFlowModel registrationFlow;
private AuthenticationFlowModel directGrantFlow;
private AuthenticationFlowModel resetCredentialsFlow;
private boolean eventsEnabled;
private long eventsExpiration;
@ -221,6 +222,7 @@ public class CachedRealm implements Serializable {
browserFlow = model.getBrowserFlow();
registrationFlow = model.getRegistrationFlow();
directGrantFlow = model.getDirectGrantFlow();
resetCredentialsFlow = model.getResetCredentialsFlow();
}
@ -483,4 +485,8 @@ public class CachedRealm implements Serializable {
public AuthenticationFlowModel getDirectGrantFlow() {
return directGrantFlow;
}
public AuthenticationFlowModel getResetCredentialsFlow() {
return resetCredentialsFlow;
}
}

View file

@ -1580,6 +1580,18 @@ public class RealmAdapter implements RealmModel {
}
@Override
public AuthenticationFlowModel getResetCredentialsFlow() {
String flowId = realm.getResetCredentialsFlow();
if (flowId == null) return null;
return getAuthenticationFlowById(flowId);
}
@Override
public void setResetCredentialsFlow(AuthenticationFlowModel flow) {
realm.setResetCredentialsFlow(flow.getId());
}
@Override
public List<AuthenticationFlowModel> getAuthenticationFlows() {
TypedQuery<AuthenticationFlowEntity> query = em.createNamedQuery("getAuthenticationFlowsByRealm", AuthenticationFlowEntity.class);

View file

@ -188,6 +188,8 @@ public class RealmEntity {
@Column(name="DIRECT_GRANT_FLOW")
protected String directGrantFlow;
@Column(name="RESET_CREDENTIALS_FLOW")
protected String resetCredentialsFlow;
@ -678,5 +680,13 @@ public class RealmEntity {
public void setDirectGrantFlow(String directGrantFlow) {
this.directGrantFlow = directGrantFlow;
}
public String getResetCredentialsFlow() {
return resetCredentialsFlow;
}
public void setResetCredentialsFlow(String resetCredentialsFlow) {
this.resetCredentialsFlow = resetCredentialsFlow;
}
}

View file

@ -1351,6 +1351,20 @@ public class RealmAdapter extends AbstractMongoAdapter<MongoRealmEntity> impleme
}
@Override
public AuthenticationFlowModel getResetCredentialsFlow() {
String flowId = realm.getResetCredentialsFlow();
if (flowId == null) return null;
return getAuthenticationFlowById(flowId);
}
@Override
public void setResetCredentialsFlow(AuthenticationFlowModel flow) {
realm.setResetCredentialsFlow(flow.getId());
updateRealm();
}
@Override
public List<AuthenticationFlowModel> getAuthenticationFlows() {

View file

@ -36,6 +36,7 @@ import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.LoginActionsService;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.util.StreamUtil;
@ -505,6 +506,7 @@ public class SamlService {
String flowId = flow.getId();
AuthenticationProcessor processor = new AuthenticationProcessor();
processor.setClientSession(clientSession)
.setFlowPath(LoginActionsService.AUTHENTICATE_PATH)
.setFlowId(flowId)
.setConnection(clientConnection)
.setEventBuilder(event)

View file

@ -224,4 +224,11 @@ public interface AuthenticationFlowContext {
*
*/
void cancelLogin();
/**
* Abort the current flow and restart it using the realm's browser login
*
* @return
*/
void resetBrowserLogin();
}

View file

@ -16,5 +16,6 @@ public enum AuthenticationFlowError {
USER_CONFLICT,
USER_TEMPORARILY_DISABLED,
INTERNAL_ERROR,
UNKNOWN_USER
UNKNOWN_USER,
RESET_TO_BROWSER_LOGIN
}

View file

@ -49,6 +49,7 @@ public class AuthenticationProcessor {
protected EventBuilder event;
protected HttpRequest request;
protected String flowId;
protected String flowPath;
/**
* This could be an error message forwarded from brokering when the broker failed authentication
* and we want to continue authentication locally. forwardedErrorMessage can then be displayed by
@ -131,6 +132,16 @@ public class AuthenticationProcessor {
return this;
}
/**
* This is the path segment to append when generating an action URL.
*
* @param flowPath
*/
public AuthenticationProcessor setFlowPath(String flowPath) {
this.flowPath = flowPath;
return this;
}
public AuthenticationProcessor setForwardedErrorMessage(String forwardedErrorMessage) {
this.forwardedErrorMessage = forwardedErrorMessage;
return this;
@ -358,7 +369,8 @@ public class AuthenticationProcessor {
@Override
public URI getActionUrl(String code) {
return LoginActionsService.authenticationFormProcessor(getUriInfo())
return LoginActionsService.loginActionsBaseUrl(getUriInfo())
.path(AuthenticationProcessor.this.flowPath)
.queryParam(OAuth2Constants.CODE, code)
.queryParam("execution", getExecution().getId())
.build(getRealm().getName());
@ -379,6 +391,11 @@ public class AuthenticationProcessor {
Response response = protocol.cancelLogin(getClientSession());
forceChallenge(response);
}
@Override
public void resetBrowserLogin() {
this.status = FlowStatus.RESET_BROWSER_LOGIN;
}
}
public void logFailure() {
@ -422,6 +439,21 @@ public class AuthenticationProcessor {
event.error(Errors.EXPIRED_CODE);
return ErrorPage.error(session, Messages.EXPIRED_CODE);
} else if (e.getError() == AuthenticationFlowError.RESET_TO_BROWSER_LOGIN) {
resetFlow(getClientSession());
AuthenticationProcessor processor = new AuthenticationProcessor();
processor.setClientSession(clientSession)
.setFlowPath(LoginActionsService.AUTHENTICATE_PATH)
.setFlowId(realm.getBrowserFlow().getId())
.setConnection(connection)
.setEventBuilder(event)
.setProtector(protector)
.setRealm(realm)
.setSession(session)
.setUriInfo(uriInfo)
.setRequest(request);
return processor.authenticate();
} else {
event.error(Errors.INVALID_USER_CREDENTIALS);
return ErrorPage.error(session, Messages.INVALID_USER);
@ -490,6 +522,8 @@ public class AuthenticationProcessor {
resetFlow(clientSession);
return authenticate();
}
UserModel authUser = clientSession.getAuthenticatedUser();
validateUser(authUser);
AuthenticationExecutionModel model = realm.getAuthenticationExecutionById(execution);
if (model == null) {
logger.debug("Cannot find execution, reseting flow");
@ -516,10 +550,11 @@ public class AuthenticationProcessor {
public void checkClientSession() {
ClientSessionCode code = new ClientSessionCode(realm, clientSession);
if (!code.isValidAction(ClientSessionModel.Action.AUTHENTICATE.name())) {
String action = ClientSessionModel.Action.AUTHENTICATE.name();
if (!code.isValidAction(action)) {
throw new AuthenticationFlowException(AuthenticationFlowError.INVALID_CLIENT_SESSION);
}
if (!code.isActionActive(ClientSessionModel.Action.AUTHENTICATE.name())) {
if (!code.isActionActive(action)) {
throw new AuthenticationFlowException(AuthenticationFlowError.EXPIRED_CODE);
}
clientSession.setTimestamp(Time.currentTime());
@ -550,12 +585,16 @@ public class AuthenticationProcessor {
String username = clientSession.getAuthenticatedUser().getUsername();
String attemptedUsername = clientSession.getNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME);
if (attemptedUsername != null) username = attemptedUsername;
String rememberMe = clientSession.getNote(Details.REMEMBER_ME);
boolean remember = rememberMe != null && rememberMe.equalsIgnoreCase("true");
if (userSession == null) { // if no authenticator attached a usersession
boolean remember = "true".equals(clientSession.getNote(Details.REMEMBER_ME));
userSession = session.sessions().createUserSession(realm, clientSession.getAuthenticatedUser(), username, connection.getRemoteAddr(), "form", remember, null, null);
userSession = session.sessions().createUserSession(realm, clientSession.getAuthenticatedUser(), username, connection.getRemoteAddr(), clientSession.getAuthMethod(), remember, null, null);
userSession.setState(UserSessionModel.State.LOGGING_IN);
userSessionCreated = true;
}
if (remember) {
event.detail(Details.REMEMBER_ME, "true");
}
TokenManager.attachClientSession(userSession, clientSession);
event.user(userSession.getUser())
.detail(Details.USERNAME, username)
@ -584,21 +623,7 @@ public class AuthenticationProcessor {
}
protected Response authenticationComplete() {
String username = clientSession.getAuthenticatedUser().getUsername();
String rememberMe = clientSession.getNote(Details.REMEMBER_ME);
boolean remember = rememberMe != null && rememberMe.equalsIgnoreCase("true");
if (userSession == null) { // if no authenticator attached a usersession
userSession = session.sessions().createUserSession(realm, clientSession.getAuthenticatedUser(), username, connection.getRemoteAddr(), clientSession.getAuthMethod(), remember, null, null);
userSession.setState(UserSessionModel.State.LOGGING_IN);
}
if (remember) {
event.detail(Details.REMEMBER_ME, "true");
}
TokenManager.attachClientSession(userSession, clientSession);
event.user(userSession.getUser())
.detail(Details.USERNAME, username)
.session(userSession);
attachSession();
return AuthenticationManager.nextActionAfterAuthentication(session, userSession, clientSession, connection, request, uriInfo, event);
}

View file

@ -166,6 +166,9 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
return sendChallenge(result, execution);
}
throw new AuthenticationFlowException(result.getError());
} else if (status == FlowStatus.RESET_BROWSER_LOGIN) {
AuthenticationProcessor.logger.debugv("reset browser login from authenticator: {0}", execution.getAuthenticator());
throw new AuthenticationFlowException(AuthenticationFlowError.RESET_TO_BROWSER_LOGIN);
} else if (status == FlowStatus.FORCE_CHALLENGE) {
processor.getClientSession().setExecutionStatus(execution.getId(), ClientSessionModel.ExecutionStatus.CHALLENGED);
return sendChallenge(result, execution);

View file

@ -42,6 +42,12 @@ public enum FlowStatus {
* a Kerberos authenticator did not see a negotiate header. There was no error, but the execution was attempted.
*
*/
ATTEMPTED
ATTEMPTED,
/**
* Aborting this flow and starting the realm's browser flow from the beginning
*
*/
RESET_BROWSER_LOGIN
}

View file

@ -137,6 +137,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
if (context.getUser() != null) {
context.getEvent().user(context.getUser());
}
logger.info("null password");
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS);
Response challengeResponse = invalidCredentials(context);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challengeResponse);
@ -145,6 +146,7 @@ public abstract class AbstractUsernameFormAuthenticator extends AbstractFormAuth
credentials.add(UserCredentialModel.password(password));
boolean valid = context.getSession().users().validCredentials(context.getRealm(), context.getUser(), credentials);
if (!valid) {
logger.info("bad password:" + password);
context.getEvent().user(context.getUser());
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS);
Response challengeResponse = invalidCredentials(context);

View file

@ -1,5 +1,6 @@
package org.keycloak.authentication.authenticators.browser;
import org.jboss.logging.Logger;
import org.jboss.resteasy.specimpl.MultivaluedMapImpl;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationProcessor;
@ -21,8 +22,9 @@ import javax.ws.rs.core.Response;
* @version $Revision: 1 $
*/
public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator implements Authenticator {
protected static Logger logger = Logger.getLogger(UsernamePasswordForm.class);
@Override
@Override
public void action(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
if (formData.containsKey("cancel")) {

View file

@ -0,0 +1,92 @@
package org.keycloak.authentication.authenticators.resetcred;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public abstract class AbstractSetRequiredActionAuthenticator implements Authenticator, AuthenticatorFactory {
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.OPTIONAL,
AuthenticationExecutionModel.Requirement.DISABLED
};
@Override
public void action(AuthenticationFlowContext context) {
}
@Override
public boolean requiresUser() {
return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public String getReferenceCategory() {
return null;
}
@Override
public boolean isConfigurable() {
return false;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return null;
}
@Override
public void close() {
}
@Override
public Authenticator create(KeycloakSession session) {
return this;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}
}

View file

@ -0,0 +1,163 @@
package org.keycloak.authentication.authenticators.resetcred;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.Urls;
import org.keycloak.services.messages.Messages;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class ResetCredentialChooseUser implements Authenticator, AuthenticatorFactory {
public static final String PROVIDER_ID = "reset-credentials-choose-user";
@Override
public void authenticate(AuthenticationFlowContext context) {
Response challenge = context.form().createPasswordReset();
context.challenge(challenge);
}
@Override
public void action(AuthenticationFlowContext context) {
EventBuilder event = context.getEvent();
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String username = formData.getFirst("username");
if (username == null || username.isEmpty()) {
event.error(Errors.USERNAME_MISSING);
Response challenge = context.form()
.setError(Messages.MISSING_USERNAME)
.createPasswordReset();
context.failureChallenge(AuthenticationFlowError.INVALID_USER, challenge);
return;
}
UserModel user = context.getSession().users().getUserByUsername(username, context.getRealm());
if (user == null && username.contains("@")) {
user = context.getSession().users().getUserByEmail(username, context.getRealm());
}
context.getClientSession().setNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, username);
// we don't want people guessing usernames, so if there is a problem, just continue, but don't set the user
// a null user will notify further executions, that this was a failure.
if (user == null) {
event.clone()
.detail(Details.USERNAME, username)
.error(Errors.USER_NOT_FOUND);
} else if (!user.isEnabled()) {
event.clone()
.detail(Details.USERNAME, username)
.user(user).error(Errors.USER_DISABLED);
} else {
context.setUser(user);
}
context.success();
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public String getDisplayType() {
return "Choose User";
}
@Override
public String getReferenceCategory() {
return null;
}
@Override
public boolean isConfigurable() {
return false;
}
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED
};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public String getHelpText() {
return "Choose a user to reset credentials for";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return null;
}
@Override
public void close() {
}
@Override
public Authenticator create(KeycloakSession session) {
return this;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -0,0 +1,218 @@
package org.keycloak.authentication.authenticators.resetcred;
import org.jboss.logging.Logger;
import org.keycloak.ClientConnection;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailProvider;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.login.LoginFormsProvider;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.HmacOTP;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.services.Urls;
import org.keycloak.services.messages.Messages;
import javax.crypto.SecretKey;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class ResetCredentialEmail implements Authenticator, AuthenticatorFactory {
public static final String RESET_CREDENTIAL_SECRET = "RESET_CREDENTIAL_SECRET";
public static final String KEY = "key";
protected static Logger logger = Logger.getLogger(ResetCredentialEmail.class);
public static final String PROVIDER_ID = "reset-credential-email";
@Override
public void authenticate(AuthenticationFlowContext context) {
UserModel user = context.getUser();
String username = context.getClientSession().getNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME);
// we don't want people guessing usernames, so if there was a problem obtaining the user, the user will be null.
// just redisplay this form
if (user == null) {
Response challenge = context.form()
.setSuccess(Messages.EMAIL_SENT)
.createForm("validate-reset-email.ftl");
context.challenge(challenge);
return;
}
EventBuilder event = context.getEvent();
// we don't want people guessing usernames, so if there is a problem, just continuously challenge
if (user.getEmail() == null || user.getEmail().trim().length() == 0) {
event.user(user)
.detail(Details.USERNAME, username)
.error(Errors.INVALID_EMAIL);
Response challenge = context.form()
.setSuccess(Messages.EMAIL_SENT)
.createForm("validate-reset-email.ftl");
context.challenge(challenge);
return;
}
// We send the secret in the email in a link as a query param. We don't need to sign it or anything because
// it can only be guessed once, and it must match watch is stored in the client session.
String secret = HmacOTP.generateSecret(10);
context.getClientSession().setNote(RESET_CREDENTIAL_SECRET, secret);
String link = UriBuilder.fromUri(context.getActionUrl()).queryParam(KEY, secret).build().toString();
long expiration = TimeUnit.SECONDS.toMinutes(context.getRealm().getAccessCodeLifespanUserAction());
try {
context.getSession().getProvider(EmailProvider.class).setRealm(context.getRealm()).setUser(user).sendPasswordReset(secret, link, expiration);
event.clone().event(EventType.SEND_RESET_PASSWORD)
.user(user)
.detail(Details.USERNAME, username)
.detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, context.getClientSession().getId()).success();
Response challenge = context.form()
.setSuccess(Messages.EMAIL_SENT)
.createForm("validate-reset-email.ftl");
context.challenge(challenge);
} catch (EmailException e) {
event.clone().event(EventType.SEND_RESET_PASSWORD)
.detail(Details.USERNAME, username)
.user(user)
.error(Errors.EMAIL_SEND_FAILED);
logger.error("Failed to send password reset email", e);
Response challenge = context.form()
.setError(Messages.EMAIL_SENT_ERROR)
.createErrorPage();
context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
}
}
@Override
public void action(AuthenticationFlowContext context) {
String secret = context.getClientSession().getNote(RESET_CREDENTIAL_SECRET);
String key = null;
if (context.getHttpRequest().getHttpMethod().equalsIgnoreCase("GET")) {
key =context.getUriInfo().getQueryParameters().getFirst(KEY);
} else if (context.getHttpRequest().getHttpMethod().equalsIgnoreCase("POST")) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
if (formData.containsKey("cancel")) {
context.resetBrowserLogin();
return;
}
key = formData.getFirst(KEY);
}
// Can only guess once! We remove the note so another guess can't happen
context.getClientSession().removeNote(RESET_CREDENTIAL_SECRET);
if (secret == null || key == null || !secret.equals(key)) {
context.getEvent().error(Errors.INVALID_USER_CREDENTIALS);
Response challenge = context.form()
.setError(Messages.INVALID_ACCESS_CODE)
.createErrorPage();
context.failure(AuthenticationFlowError.INTERNAL_ERROR, challenge);
return;
}
context.success();
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public String getDisplayType() {
return "Reset Via Email";
}
@Override
public String getReferenceCategory() {
return null;
}
@Override
public boolean isConfigurable() {
return false;
}
public static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED
};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public String getHelpText() {
return "Send email to user and wait for response.";
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return null;
}
@Override
public void close() {
}
@Override
public Authenticator create(KeycloakSession session) {
return this;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -0,0 +1,45 @@
package org.keycloak.authentication.authenticators.resetcred;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class ResetOTP extends AbstractSetRequiredActionAuthenticator {
public static final String PROVIDER_ID = "reset-otp";
@Override
public void authenticate(AuthenticationFlowContext context) {
if (context.getExecution().isRequired() ||
(context.getExecution().isOptional() &&
configuredFor(context))) {
context.getUser().addRequiredAction(UserModel.RequiredAction.CONFIGURE_TOTP);
}
context.success();
}
protected boolean configuredFor(AuthenticationFlowContext context) {
return context.getSession().users().configuredForCredentialType(context.getRealm().getOTPPolicy().getType(), context.getRealm(), context.getUser());
}
@Override
public String getDisplayType() {
return "Reset OTP";
}
@Override
public String getHelpText() {
return "Sets the Configure OTP required action if execution is REQUIRED. Will also set it if execution is OPTIONAL and the OTP is currently configured for it.";
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -0,0 +1,53 @@
package org.keycloak.authentication.authenticators.resetcred;
import org.keycloak.Config;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.List;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class ResetPassword extends AbstractSetRequiredActionAuthenticator {
public static final String PROVIDER_ID = "reset-password";
@Override
public void authenticate(AuthenticationFlowContext context) {
if (context.getExecution().isRequired() ||
(context.getExecution().isOptional() &&
configuredFor(context))) {
context.getUser().addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
}
context.success();
}
protected boolean configuredFor(AuthenticationFlowContext context) {
return context.getSession().users().configuredForCredentialType(UserCredentialModel.PASSWORD, context.getRealm(), context.getUser());
}
@Override
public String getDisplayType() {
return "Reset Password";
}
@Override
public String getHelpText() {
return "Sets the Update Password required action if execution is REQUIRED. Will also set it if execution is OPTIONAL and the password is currently configured for it.";
}
@Override
public String getId() {
return PROVIDER_ID;
}
}

View file

@ -27,6 +27,7 @@ import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.ClientSessionCode;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.Urls;
import org.keycloak.services.resources.LoginActionsService;
import javax.ws.rs.GET;
import javax.ws.rs.core.Context;
@ -267,6 +268,7 @@ public class AuthorizationEndpoint {
String flowId = flow.getId();
AuthenticationProcessor processor = new AuthenticationProcessor();
processor.setClientSession(clientSession)
.setFlowPath(LoginActionsService.AUTHENTICATE_PATH)
.setFlowId(flowId)
.setConnection(clientConnection)
.setEventBuilder(event)

View file

@ -152,12 +152,16 @@ public class Urls {
return loginActionsBase(baseUri).path(LoginActionsService.class, "emailVerification");
}
public static URI loginPasswordReset(URI baseUri, String realmId) {
return loginPasswordResetBuilder(baseUri).build(realmId);
public static URI loginResetCredentials(URI baseUri, String realmId) {
return loginResetCredentialsBuilder(baseUri).build(realmId);
}
public static UriBuilder loginPasswordResetBuilder(URI baseUri) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "passwordReset");
public static UriBuilder recoverPasswordBuilder(URI baseUri) {
return loginActionsBase(baseUri).path(LoginActionsService.class, "recoverPassword");
}
public static UriBuilder loginResetCredentialsBuilder(URI baseUri) {
return loginActionsBase(baseUri).path(LoginActionsService.RESET_CREDENTIALS_PATH);
}
public static URI loginUsernameReminder(URI baseUri, String realmId) {

View file

@ -473,6 +473,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
String flowId = flow.getId();
AuthenticationProcessor processor = new AuthenticationProcessor();
processor.setClientSession(clientSession)
.setFlowPath(LoginActionsService.AUTHENTICATE_PATH)
.setFlowId(flowId)
.setConnection(clientConnection)
.setEventBuilder(event)

View file

@ -29,6 +29,7 @@ import org.keycloak.authentication.RequiredActionContext;
import org.keycloak.authentication.RequiredActionContextResult;
import org.keycloak.authentication.RequiredActionFactory;
import org.keycloak.authentication.RequiredActionProvider;
import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator;
import org.keycloak.email.EmailException;
import org.keycloak.email.EmailProvider;
import org.keycloak.events.Details;
@ -50,13 +51,10 @@ import org.keycloak.models.UserModel;
import org.keycloak.models.UserModel.RequiredAction;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.models.utils.DefaultAuthenticationFlows;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.models.utils.TimeBasedOTP;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.RestartLoginCookie;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
@ -70,7 +68,6 @@ import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Context;
@ -93,6 +90,9 @@ public class LoginActionsService {
protected static final Logger logger = Logger.getLogger(LoginActionsService.class);
public static final String ACTION_COOKIE = "KEYCLOAK_ACTION";
public static final String AUTHENTICATE_PATH = "authenticate";
public static final String REGISTRATION_PATH = "registration";
public static final String RESET_CREDENTIALS_PATH = "reset-credentials";
private RealmModel realm;
@ -226,7 +226,7 @@ public class LoginActionsService {
ClientSessionModel clientSession = RestartLoginCookie.restartSession(session, realm, code);
if (clientSession != null) {
event.clone().detail(Details.RESTART_AFTER_TIMEOUT, "true").error(Errors.EXPIRED_CODE);
response = processFlow(null, clientSession, realm.getBrowserFlow(), Messages.LOGIN_TIMEOUT);
response = processFlow(null, clientSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), Messages.LOGIN_TIMEOUT);
return false;
}
} catch (Exception e) {
@ -268,7 +268,7 @@ public class LoginActionsService {
* @param code
* @return
*/
@Path("authenticate")
@Path(AUTHENTICATE_PATH)
@GET
public Response authenticate(@QueryParam("code") String code,
@QueryParam("execution") String execution) {
@ -290,12 +290,13 @@ public class LoginActionsService {
}
protected Response processAuthentication(String execution, ClientSessionModel clientSession, String errorMessage) {
return processFlow(execution, clientSession, realm.getBrowserFlow(), errorMessage);
return processFlow(execution, clientSession, AUTHENTICATE_PATH, realm.getBrowserFlow(), errorMessage);
}
protected Response processFlow(String execution, ClientSessionModel clientSession, AuthenticationFlowModel flow, String errorMessage) {
protected Response processFlow(String execution, ClientSessionModel clientSession, String flowPath, AuthenticationFlowModel flow, String errorMessage) {
AuthenticationProcessor processor = new AuthenticationProcessor();
processor.setClientSession(clientSession)
.setFlowPath(flowPath)
.setFlowId(flow.getId())
.setConnection(clientConnection)
.setEventBuilder(event)
@ -323,7 +324,7 @@ public class LoginActionsService {
* @param code
* @return
*/
@Path("authenticate")
@Path(AUTHENTICATE_PATH)
@POST
public Response authenticateForm(@QueryParam("code") String code,
@QueryParam("execution") String execution) {
@ -338,8 +339,39 @@ public class LoginActionsService {
return processAuthentication(execution, clientSession, null);
}
@Path(RESET_CREDENTIALS_PATH)
@POST
public Response resetCredentialsPOST(@QueryParam("code") String code,
@QueryParam("execution") String execution) {
return resetCredentials(code, execution);
}
@Path(RESET_CREDENTIALS_PATH)
@GET
public Response resetCredentialsGET(@QueryParam("code") String code,
@QueryParam("execution") String execution) {
return resetCredentials(code, execution);
}
protected Response resetCredentials(String code, String execution) {
event.event(EventType.RESET_PASSWORD);
Checks checks = new Checks();
if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name())) {
return checks.response;
}
final ClientSessionCode clientCode = checks.clientCode;
final ClientSessionModel clientSession = clientCode.getClientSession();
return processResetCredentials(execution, clientSession, null);
}
protected Response processResetCredentials(String execution, ClientSessionModel clientSession, String errorMessage) {
return processFlow(execution, clientSession, RESET_CREDENTIALS_PATH, realm.getResetCredentialsFlow(), errorMessage);
}
protected Response processRegistration(String execution, ClientSessionModel clientSession, String errorMessage) {
return processFlow(execution, clientSession, realm.getRegistrationFlow(), errorMessage);
return processFlow(execution, clientSession, REGISTRATION_PATH, realm.getRegistrationFlow(), errorMessage);
}
@ -349,7 +381,7 @@ public class LoginActionsService {
* @param code
* @return
*/
@Path("registration")
@Path(REGISTRATION_PATH)
@GET
public Response registerPage(@QueryParam("code") String code,
@QueryParam("execution") String execution) {
@ -380,7 +412,7 @@ public class LoginActionsService {
* @param code
* @return
*/
@Path("registration")
@Path(REGISTRATION_PATH)
@POST
public Response processRegister(@QueryParam("code") String code,
@QueryParam("execution") String execution) {
@ -393,10 +425,6 @@ public class LoginActionsService {
if (!checks.verifyCode(code, ClientSessionModel.Action.AUTHENTICATE.name())) {
return checks.response;
}
if (!realm.isRegistrationAllowed()) {
event.error(Errors.REGISTRATION_DISABLED);
return ErrorPage.error(session, Messages.REGISTRATION_NOT_ALLOWED);
}
ClientSessionCode clientCode = checks.clientCode;
ClientSessionModel clientSession = clientCode.getClientSession();
@ -649,13 +677,10 @@ public class LoginActionsService {
event.event(EventType.UPDATE_PASSWORD).success();
if (clientSession.getAction().equals(ClientSessionModel.Action.RECOVER_PASSWORD.name())) {
String actionCookieValue = getActionCookie();
if (actionCookieValue == null || !actionCookieValue.equals(userSession.getId())) {
session.sessions().removeClientSession(realm, clientSession);
return session.getProvider(LoginFormsProvider.class)
.setSuccess(Messages.ACCOUNT_PASSWORD_UPDATED)
.createInfoPage();
}
session.sessions().removeClientSession(realm, clientSession);
return session.getProvider(LoginFormsProvider.class)
.setSuccess(Messages.ACCOUNT_PASSWORD_UPDATED)
.createInfoPage();
}
event = event.clone().event(EventType.LOGIN);
@ -714,110 +739,30 @@ public class LoginActionsService {
}
}
@Path("password-reset")
/**
* Initiated by admin, not the user on login
*
* @param key
* @return
*/
@Path("recover-password")
@GET
public Response passwordReset(@QueryParam("code") String code, @QueryParam("key") String key) {
public Response recoverPassword(@QueryParam("key") String key) {
event.event(EventType.RESET_PASSWORD);
if (!realm.isResetPasswordAllowed()) {
event.error(Errors.RESET_CREDENTIAL_DISABLED);
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
}
if (key != null) {
Checks checks = new Checks();
if (!checks.verifyCode(key, ClientSessionModel.Action.RECOVER_PASSWORD.name())) {
return checks.response;
event.error(Errors.RESET_CREDENTIAL_DISABLED);
return ErrorPage.error(session, Messages.INVALID_CODE);
}
ClientSessionCode accessCode = checks.clientCode;
return session.getProvider(LoginFormsProvider.class)
.setClientSessionCode(accessCode.getCode())
.createResponse(RequiredAction.UPDATE_PASSWORD);
} else {
return session.getProvider(LoginFormsProvider.class)
.setClientSessionCode(code)
.createPasswordReset();
}
}
@Path("password-reset")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response sendPasswordReset(@QueryParam("code") String code,
final MultivaluedMap<String, String> formData) {
event.event(EventType.SEND_RESET_PASSWORD);
if (!realm.isResetPasswordAllowed()) {
event.error(Errors.RESET_CREDENTIAL_DISABLED);
return ErrorPage.error(session, Messages.RESET_CREDENTIAL_NOT_ALLOWED);
return ErrorPage.error(session, Messages.INVALID_CODE);
}
Checks checks = new Checks();
if (!checks.verifyCode(code)) {
return checks.response;
}
final ClientSessionCode accessCode = checks.clientCode;
final ClientSessionModel clientSession = accessCode.getClientSession();
ClientModel client = clientSession.getClient();
String username = formData.getFirst("username");
if (username == null || username.isEmpty()) {
event.error(Errors.USERNAME_MISSING);
return session.getProvider(LoginFormsProvider.class)
.setError(Messages.MISSING_USERNAME)
.setClientSessionCode(accessCode.getCode())
.createPasswordReset();
}
event.client(client.getClientId())
.detail(Details.REDIRECT_URI, clientSession.getRedirectUri())
.detail(Details.RESPONSE_TYPE, "code")
.detail(Details.AUTH_METHOD, "form")
.detail(Details.USERNAME, username);
UserModel user = session.users().getUserByUsername(username, realm);
if (user == null && username.contains("@")) {
user = session.users().getUserByEmail(username, realm);
}
if (user == null) {
event.error(Errors.USER_NOT_FOUND);
} else if (!user.isEnabled()) {
event.user(user).error(Errors.USER_DISABLED);
} else if (user.getEmail() == null || user.getEmail().trim().length() == 0) {
event.user(user).error(Errors.INVALID_EMAIL);
} else {
event.user(user);
UserSessionModel userSession = session.sessions().createUserSession(realm, user, username, clientConnection.getRemoteAddr(), "form", false, null, null);
event.session(userSession);
TokenManager.attachClientSession(userSession, clientSession);
accessCode.setAction(ClientSessionModel.Action.RECOVER_PASSWORD.name());
try {
UriBuilder builder = Urls.loginPasswordResetBuilder(uriInfo.getBaseUri());
builder.queryParam("key", accessCode.getCode());
String link = builder.build(realm.getName()).toString();
long expiration = TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction());
this.session.getProvider(EmailProvider.class).setRealm(realm).setUser(user).sendPasswordReset(link, expiration);
event.detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, clientSession.getId()).success();
} catch (EmailException e) {
event.error(Errors.EMAIL_SEND_FAILED);
logger.error("Failed to send password reset email", e);
return session.getProvider(LoginFormsProvider.class)
.setError(Messages.EMAIL_SENT_ERROR)
.setClientSessionCode(accessCode.getCode())
.createErrorPage();
}
createActionCookie(realm, uriInfo, clientConnection, userSession.getId());
}
return session.getProvider(LoginFormsProvider.class)
.setSuccess(Messages.EMAIL_SENT)
.setClientSessionCode(accessCode.getCode())
.createPasswordReset();
}
private String getActionCookie() {
@ -836,6 +781,7 @@ public class LoginActionsService {
.session(clientSession.getUserSession().getId())
.detail(Details.CODE_ID, clientSession.getId())
.detail(Details.REDIRECT_URI, clientSession.getRedirectUri())
.detail(Details.USERNAME, clientSession.getNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME))
.detail(Details.RESPONSE_TYPE, "code");
UserSessionModel userSession = clientSession.getUserSession();

View file

@ -854,13 +854,13 @@ public class UsersResource {
accessCode.setAction(ClientSessionModel.Action.RECOVER_PASSWORD.name());
try {
UriBuilder builder = Urls.loginPasswordResetBuilder(uriInfo.getBaseUri());
UriBuilder builder = Urls.recoverPasswordBuilder(uriInfo.getBaseUri());
builder.queryParam("key", accessCode.getCode());
String link = builder.build(realm.getName()).toString();
long expiration = TimeUnit.SECONDS.toMinutes(realm.getAccessCodeLifespanUserAction());
this.session.getProvider(EmailProvider.class).setRealm(realm).setUser(user).sendPasswordReset(link, expiration);
this.session.getProvider(EmailProvider.class).setRealm(realm).setUser(user).sendChangePassword(link, expiration);
//audit.user(user).detail(Details.EMAIL, user.getEmail()).detail(Details.CODE_ID, accessCode.getCodeId()).success();

View file

@ -5,3 +5,7 @@ org.keycloak.authentication.authenticators.browser.SpnegoAuthenticatorFactory
org.keycloak.authentication.authenticators.directgrant.ValidateOTP
org.keycloak.authentication.authenticators.directgrant.ValidatePassword
org.keycloak.authentication.authenticators.directgrant.ValidateUsername
org.keycloak.authentication.authenticators.resetcred.ResetCredentialChooseUser
org.keycloak.authentication.authenticators.resetcred.ResetCredentialEmail
org.keycloak.authentication.authenticators.resetcred.ResetOTP
org.keycloak.authentication.authenticators.resetcred.ResetPassword

View file

@ -204,7 +204,7 @@ public class RequiredActionTotpSetupTest {
// Login with one-time password
loginTotpPage.login(totp.generateTOTP(totpCode));
loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, "setuptotp2").assertEvent();
loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, "setupTotp2").assertEvent();
// Open account page
accountTotpPage.open();
@ -227,11 +227,11 @@ public class RequiredActionTotpSetupTest {
totpPage.assertCurrent();
totpPage.configure(totp.generateTOTP(totpPage.getTotpSecret()));
String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setuptotp2").assertEvent().getSessionId();
String sessionId = events.expectRequiredAction(EventType.UPDATE_TOTP).user(userId).detail(Details.USERNAME, "setupTotp2").assertEvent().getSessionId();
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectLogin().user(userId).session(sessionId).detail(Details.USERNAME, "setuptotp2").assertEvent();
events.expectLogin().user(userId).session(sessionId).detail(Details.USERNAME, "setupTotp2").assertEvent();
}
@Test

View file

@ -21,6 +21,7 @@
*/
package org.keycloak.testsuite.forms;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
@ -44,6 +45,7 @@ import org.keycloak.testsuite.pages.InfoPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.LoginPasswordResetPage;
import org.keycloak.testsuite.pages.LoginPasswordUpdatePage;
import org.keycloak.testsuite.pages.ValidatePassworrdEmailResetPage;
import org.keycloak.testsuite.rule.GreenMailRule;
import org.keycloak.testsuite.rule.KeycloakRule;
import org.keycloak.testsuite.rule.WebResource;
@ -57,6 +59,8 @@ import javax.mail.internet.MimeMessage;
import java.io.IOException;
import java.util.Collections;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.Assert.*;
@ -65,6 +69,7 @@ import static org.junit.Assert.*;
*/
public class ResetPasswordTest {
static int lifespan = 0;
@ClassRule
public static KeycloakRule keycloakRule = new KeycloakRule((new KeycloakRule.KeycloakSetup() {
@Override
@ -81,6 +86,7 @@ public class ResetPasswordTest {
user.updateCredential(creds);
appRealm.setEventsListeners(Collections.singleton("dummy"));
lifespan = appRealm.getAccessCodeLifespanUserAction();
}
}));
@ -113,12 +119,30 @@ public class ResetPasswordTest {
@WebResource
protected LoginPasswordResetPage resetPasswordPage;
@WebResource
protected ValidatePassworrdEmailResetPage validateResetPage;
@WebResource
protected LoginPasswordUpdatePage updatePasswordPage;
@Rule
public AssertEvents events = new AssertEvents(keycloakRule);
@Before
public void resetPasswordToOriginal() {
keycloakRule.configure(new KeycloakRule.KeycloakSetup() {
@Override
public void config(RealmManager manager, RealmModel adminstrationRealm, RealmModel appRealm) {
UserModel user = session.users().getUserByUsername("login-test", appRealm);
UserCredentialModel creds = new UserCredentialModel();
creds.setType(CredentialRepresentation.PASSWORD);
creds.setValue("password");
user.updateCredential(creds);
}
});
}
@Test
public void resetPassword() throws IOException, MessagingException {
resetPassword("login-test");
@ -133,17 +157,23 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword("login-test");
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
String src = driver.getPageSource();
resetPasswordPage.backToLogin();
validateResetPage.cancel();
assertTrue(loginPage.isCurrent());
loginPage.login("login-test", "password");
String currentUrl = driver.getCurrentUrl();
String src = driver.getPageSource();
System.out.println("currentUrl: " + currentUrl);
events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent();
assertEquals(1, greenMail.getReceivedMessages().length);
@ -169,17 +199,19 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword("test-user@localhost");
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).detail(Details.USERNAME, "test-user@localhost").detail(Details.EMAIL, "test-user@localhost").assertEvent().getSessionId();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).detail(Details.USERNAME, "test-user@localhost")
.session((String) null)
.detail(Details.EMAIL, "test-user@localhost").assertEvent();
resetPasswordPage.backToLogin();
validateResetPage.cancel();
assertTrue(loginPage.isCurrent());
loginPage.login("login@test.com", "password");
Event loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent();
Event loginEvent = events.expectLogin().user(userId).detail(Details.USERNAME, "login@test.com").assertEvent();
String code = oauth.getCurrentQuery().get("code");
OAuthClient.AccessTokenResponse tokenResponse = oauth.doAccessTokenRequest(code, "password");
@ -203,9 +235,14 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword(username);
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
String sessionId = events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.user(userId)
.detail(Details.USERNAME, username)
.detail(Details.EMAIL, "login@test.com")
.session((String)null)
.assertEvent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
@ -221,7 +258,7 @@ public class ResetPasswordTest {
updatePasswordPage.changePassword("resetPassword", "resetPassword");
events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).session(sessionId).detail(Details.USERNAME, username).assertEvent();
String sessionId = events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).detail(Details.USERNAME, username).assertEvent().getSessionId();
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
@ -248,10 +285,10 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword(username);
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
String sessionId = events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId)
.detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).session((String)null)
.detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
@ -265,12 +302,12 @@ public class ResetPasswordTest {
updatePasswordPage.changePassword(password, password);
events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).session(sessionId)
.detail(Details.USERNAME, username).assertEvent();
String sessionId = events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId)
.detail(Details.USERNAME, username).assertEvent().getSessionId();
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectLogin().user(userId).detail(Details.USERNAME, username).session(sessionId).assertEvent();
events.expectLogin().user(userId).detail(Details.USERNAME, username).assertEvent();
oauth.openLogout();
@ -285,10 +322,10 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword(username);
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId)
.detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).session((String)null)
.detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
@ -315,13 +352,13 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword("invalid");
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
assertEquals(0, greenMail.getReceivedMessages().length);
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user((String) null).session((String) null).detail(Details.USERNAME, "invalid").removeDetail(Details.EMAIL).removeDetail(Details.CODE_ID).error("user_not_found").assertEvent();
events.expectRequiredAction(EventType.RESET_PASSWORD).user((String) null).session((String) null).detail(Details.USERNAME, "invalid").removeDetail(Details.EMAIL).removeDetail(Details.CODE_ID).error("user_not_found").assertEvent();
}
@Test
@ -339,7 +376,7 @@ public class ResetPasswordTest {
assertEquals(0, greenMail.getReceivedMessages().length);
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).client((String) null).user((String) null).session((String) null).clearDetails().error("username_missing").assertEvent();
events.expectRequiredAction(EventType.RESET_PASSWORD).user((String) null).session((String) null).clearDetails().error("username_missing").assertEvent();
}
@ -353,9 +390,11 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword("login-test");
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
String sessionId = events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.session((String)null)
.user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
@ -365,13 +404,13 @@ public class ResetPasswordTest {
String changePasswordUrl = getPasswordResetEmailLink(message);
Time.setOffset(350);
Time.setOffset(1800 + 23);
driver.navigate().to(changePasswordUrl.trim());
errorPage.assertCurrent();
loginPage.assertCurrent();
assertEquals("Login timeout. Please login again.", errorPage.getError());
assertEquals("You took too long to login. Login process starting from beginning.", loginPage.getError());
events.expectRequiredAction(EventType.RESET_PASSWORD).error("expired_code").client("test-app").user((String) null).session((String) null).clearDetails().assertEvent();
} finally {
@ -396,13 +435,13 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword("login-test");
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
assertEquals(0, greenMail.getReceivedMessages().length);
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("user_disabled").assertEvent();
events.expectRequiredAction(EventType.RESET_PASSWORD).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("user_disabled").assertEvent();
} finally {
keycloakRule.configure(new KeycloakRule.KeycloakSetup() {
@Override
@ -434,13 +473,13 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword("login-test");
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
assertEquals(0, greenMail.getReceivedMessages().length);
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD_ERROR).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("invalid_email").assertEvent();
events.expectRequiredAction(EventType.RESET_PASSWORD_ERROR).session((String) null).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error("invalid_email").assertEvent();
} finally {
keycloakRule.configure(new KeycloakRule.KeycloakSetup() {
@Override
@ -476,7 +515,9 @@ public class ResetPasswordTest {
assertEquals(0, greenMail.getReceivedMessages().length);
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD_ERROR).user(userId).detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error(Errors.EMAIL_SEND_FAILED).assertEvent();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD_ERROR).user(userId)
.session((String)null)
.detail(Details.USERNAME, "login-test").removeDetail(Details.CODE_ID).error(Errors.EMAIL_SEND_FAILED).assertEvent();
} finally {
keycloakRule.configure(new KeycloakRule.KeycloakSetup() {
@Override
@ -503,7 +544,7 @@ public class ResetPasswordTest {
resetPasswordPage.changePassword("login-test");
resetPasswordPage.assertCurrent();
validateResetPage.assertCurrent();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
@ -513,7 +554,7 @@ public class ResetPasswordTest {
String changePasswordUrl = getPasswordResetEmailLink(message);
String sessionId = events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).session((String)null).user(userId).detail(Details.USERNAME, "login-test").detail(Details.EMAIL, "login@test.com").assertEvent();
driver.navigate().to(changePasswordUrl.trim());
@ -525,7 +566,7 @@ public class ResetPasswordTest {
updatePasswordPage.changePassword("resetPasswordWithPasswordPolicy", "resetPasswordWithPasswordPolicy");
events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).session(sessionId).detail(Details.USERNAME, "login-test").assertEvent();
String sessionId = events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).detail(Details.USERNAME, "login-test").assertEvent().getSessionId();
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
@ -586,46 +627,80 @@ public class ResetPasswordTest {
}
@Test
public void resetPasswordNewBrowserSession() throws IOException, MessagingException {
String username = "login-test";
public void resetPasswordByCode() throws IOException, MessagingException {
try {
String username = "login@test.com";
loginPage.open();
loginPage.resetPassword();
loginPage.open();
loginPage.resetPassword();
resetPasswordPage.assertCurrent();
resetPasswordPage.assertCurrent();
resetPasswordPage.changePassword(username);
resetPasswordPage.changePassword(username);
validateResetPage.assertCurrent();
resetPasswordPage.assertCurrent();
events.expectRequiredAction(EventType.SEND_RESET_PASSWORD)
.user(userId)
.detail(Details.USERNAME, username)
.detail(Details.EMAIL, "login@test.com")
.session((String) null)
.assertEvent();
String sessionId = events.expectRequiredAction(EventType.SEND_RESET_PASSWORD).user(userId).detail(Details.USERNAME, username).detail(Details.EMAIL, "login@test.com").assertEvent().getSessionId();
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
assertEquals("You should receive an email shortly with further instructions.", resetPasswordPage.getSuccessMessage());
assertEquals(1, greenMail.getReceivedMessages().length);
assertEquals(1, greenMail.getReceivedMessages().length);
MimeMessage message = greenMail.getReceivedMessages()[0];
MimeMessage message = greenMail.getReceivedMessages()[0];
String code = getTemporaryCode(message);
String changePasswordUrl = getPasswordResetEmailLink(message);
validateResetPage.submitCode(code);
driver.manage().deleteAllCookies();
updatePasswordPage.assertCurrent();
driver.navigate().to(changePasswordUrl.trim());
updatePasswordPage.changePassword("resetPassword", "resetPassword");
updatePasswordPage.assertCurrent();
String sessionId = events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).detail(Details.USERNAME, username).assertEvent().getSessionId();
updatePasswordPage.changePassword("resetPassword", "resetPassword");
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
events.expectRequiredAction(EventType.UPDATE_PASSWORD).user(userId).session(sessionId).detail(Details.USERNAME, username).assertEvent();
events.expectLogin().user(userId).detail(Details.USERNAME, username).session(sessionId).assertEvent();
assertTrue(infoPage.isCurrent());
assertEquals("Your password has been updated.", infoPage.getInfo());
oauth.openLogout();
loginPage.open();
events.expectLogout(sessionId).user(userId).session(sessionId).assertEvent();
assertTrue(loginPage.isCurrent());
loginPage.open();
loginPage.login("login-test", "resetPassword");
events.expectLogin().user(userId).detail(Details.USERNAME, "login-test").assertEvent();
assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
} finally {
}
}
private String getTemporaryCode(MimeMessage message) throws IOException, MessagingException {
Multipart multipart = (Multipart) message.getContent();
final String textContentType = multipart.getBodyPart(0).getContentType();
assertEquals("text/plain; charset=UTF-8", textContentType);
final String textBody = (String) multipart.getBodyPart(0).getContent();
Pattern pattern = Pattern.compile("Temporary Code: ([^\\s]*)");
Matcher matcher = pattern.matcher(textBody);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
private String getPasswordResetEmailLink(MimeMessage message) throws IOException, MessagingException {
Multipart multipart = (Multipart) message.getContent();

View file

@ -95,6 +95,9 @@ public class EmailTest {
}
});
loginPage.open();
loginPage.resetPassword();
resetPasswordPage.changePassword("login-test");
assertEquals(2, greenMail.getReceivedMessages().length);

View file

@ -0,0 +1,73 @@
/*
* JBoss, Home of Professional Open Source.
* Copyright 2012, Red Hat, Inc., and individual contributors
* as indicated by the @author tags. See the copyright.txt file in the
* distribution for a full listing of individual contributors.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.keycloak.testsuite.pages;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class ValidatePassworrdEmailResetPage extends AbstractPage {
@FindBy(id = "key")
private WebElement keyInput;
@FindBy(id="kc-submit")
private WebElement submitButton;
@FindBy(id="kc-cancel")
private WebElement cancelButton;
@FindBy(className = "feedback-success")
private WebElement emailSuccessMessage;
@FindBy(className = "feedback-error")
private WebElement emailErrorMessage;
public void submitCode(String code) {
keyInput.sendKeys(code);
submitButton.click();
}
public void cancel() {
cancelButton.click();
}
public boolean isCurrent() {
return driver.getTitle().equals("Forgot Your Password?");
}
public void open() {
throw new UnsupportedOperationException();
}
public String getSuccessMessage() {
return emailSuccessMessage != null ? emailSuccessMessage.getText() : null;
}
public String getErrorMessage() {
return emailErrorMessage != null ? emailErrorMessage.getText() : null;
}
}