Skip to content

Commit

Permalink
chore: rework rest password mgmt ops
Browse files Browse the repository at this point in the history
  • Loading branch information
mmoayyed committed Feb 23, 2025
1 parent 319ec68 commit f818cfd
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 91 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import org.apereo.cas.configuration.features.CasFeatureModule;
import org.apereo.cas.configuration.support.RequiredProperty;
import org.apereo.cas.configuration.support.RequiresModule;

import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;

import java.io.Serial;
import java.io.Serializable;
import java.util.HashMap;
import java.util.Map;

/**
* This is {@link RestfulPasswordManagementProperties}.
Expand Down Expand Up @@ -95,4 +95,10 @@ public class RestfulPasswordManagementProperties implements CasFeatureModule, Se
*/
@RequiredProperty
private String fieldNamePasswordOld = "oldPassword";

/**
* Additional headers to be included in REST API calls for password management.
* The map keys are header names and the corresponding values are header values.
*/
private Map<String, String> headers = new HashMap<>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ REST support is enabled by including the following dependencies in the WAR overl

{% include_cached casproperties.html properties="cas.authn.pm.rest" %}

| Endpoint | Method | Headers | Expected Response |
|------------------------|--------|---------------------------------------|--------------------------------------------|
| Get Email Address | `GET` | `username` | `200`. Email address in the body. |
| Get Phone Number | `GET` | `username` | `200`. Phone number in the body. |
| Get Security Questions | `GET` | `username` | `200`. Security questions map in the body. |
| Update Password | `POST` | `username`, `password`, `oldPassword` | `200`. `true/false` in the body. |
| Unlock Account | `POST` | `username` in the path | `200`. `true/false` in the body. |
| Endpoint | Method | Query Parameter(s) | Request Body | Expected Response |
|---------------------------|--------|--------------------|---------------------------------------|--------------------------------------------|
| Get Email Address | `GET` | `username` | None | `200`. Email address in the body. |
| Get Phone Number | `GET` | `username` | None | `200`. Phone number in the body. |
| Get Security Questions | `GET` | `username` | None | `200`. Security questions map in the body. |
| Update Security Questions | `POST` | `username` | Security questions map in the body. | `200`. `true/false` in the body. |
| Update Password | `POST` | None | `username`, `password`, `oldPassword` | `200`. `true/false` in the body. |
| Unlock Account | `POST` | `username` | None | `200`. `true/false` in the body. |
By default, all requests are submitted using the `Accept` header `application/json`, `Content-Type` header set to `application/json`
and `Accept-Charset` header set to `UTF-8`.
3 changes: 2 additions & 1 deletion docs/cas-server-documentation/release_notes/RC6.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ test coverage of the CAS codebase is approximately `94%`.

## Other Stuff

- Storing [attribute consent decisions](../integration/Attribute-Release-Consent-Storage-REST.html) is now reworked to be more compatible with REST design principals.
- Storing [attribute consent decisions](../integration/Attribute-Release-Consent-Storage-REST.html) is reworked to be more compatible with REST design principals.
- [Rest password management](../password_management/Password-Management-REST.html) operations are reworked to be more compatible with REST design principals.
- Synchronizing passwords can now be used using a [REST API](../password_management/Password-Synchronization.html).
- Column sizes for [JDBC Audit](../audits/Audits-Database.html) records are slightly adjusted to better accommodate larger data.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@
import org.apereo.cas.audit.AuditableActions;
import org.apereo.cas.configuration.CasConfigurationProperties;
import org.apereo.cas.configuration.support.Beans;
import org.apereo.cas.pm.InvalidPasswordException;
import org.apereo.cas.pm.PasswordChangeRequest;
import org.apereo.cas.pm.PasswordHistoryService;
import org.apereo.cas.pm.PasswordManagementQuery;
import org.apereo.cas.pm.PasswordManagementService;
import org.apereo.cas.util.LoggingUtils;
import org.apereo.cas.util.crypto.CipherExecutor;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -22,7 +20,6 @@
import org.apereo.inspektr.common.web.ClientInfoHolder;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.NumericDate;

import java.io.Serializable;
import java.util.UUID;

Expand Down Expand Up @@ -142,12 +139,5 @@ public boolean change(final PasswordChangeRequest bean) throws Throwable {
return false;
}

/**
* Change password internally, by the impl.
*
* @param bean the bean
* @return true/false
* @throws InvalidPasswordException if new password fails downstream validation
*/
public abstract boolean changeInternal(PasswordChangeRequest bean) throws Throwable;
protected abstract boolean changeInternal(PasswordChangeRequest bean) throws Throwable;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;

/**
* This is {@link CasRestPasswordManagementAutoConfiguration}.
Expand All @@ -31,30 +35,43 @@
@AutoConfiguration
public class CasRestPasswordManagementAutoConfiguration {

private static RestTemplate buildRestTemplateBuilder(final RestTemplateBuilder restTemplateBuilder,
final CasConfigurationProperties casProperties) {
@RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
@Bean
@ConditionalOnMissingBean(name = "restPasswordChangeService")
public PasswordManagementService passwordChangeService(
@Qualifier("passwordChangeServiceRestTemplate")
final RestTemplate passwordChangeServiceRestTemplate,
final CasConfigurationProperties casProperties,
@Qualifier("passwordManagementCipherExecutor")
final CipherExecutor passwordManagementCipherExecutor,
@Qualifier(PasswordHistoryService.BEAN_NAME)
final PasswordHistoryService passwordHistoryService) {
return new RestPasswordManagementService(passwordManagementCipherExecutor,
casProperties, passwordChangeServiceRestTemplate, passwordHistoryService);
}

@RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
@Bean
@ConditionalOnMissingBean(name = "passwordChangeServiceRestTemplate")
public RestTemplate passwordChangeServiceRestTemplate(final CasConfigurationProperties casProperties) {
val pmRest = casProperties.getAuthn().getPm().getRest();

val username = pmRest.getEndpointUsername();
val password = pmRest.getEndpointPassword();

var builder = new RestTemplateBuilder();
if (StringUtils.isNotBlank(username) && StringUtils.isNotBlank(password)) {
LOGGER.debug("Configuring basic authentication for password management via REST for [{}]", username);
return restTemplateBuilder.basicAuthentication(username, password).build();
builder = builder.basicAuthentication(username, password, StandardCharsets.UTF_8);
}
builder = builder.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name());
for (val entry : pmRest.getHeaders().entrySet()) {
LOGGER.debug("Configuring header [{}] with value [{}]", entry.getKey(), entry.getValue());
builder = builder.defaultHeader(entry.getKey(), org.springframework.util.StringUtils.commaDelimitedListToStringArray(entry.getValue()));
}
LOGGER.warn("Basic authentication for password management via REST is turned off");
return restTemplateBuilder.build();
}

@RefreshScope(proxyMode = ScopedProxyMode.DEFAULT)
@Bean
public PasswordManagementService passwordChangeService(final RestTemplateBuilder restTemplateBuilder,
final CasConfigurationProperties casProperties,
@Qualifier("passwordManagementCipherExecutor")
final CipherExecutor passwordManagementCipherExecutor,
@Qualifier(PasswordHistoryService.BEAN_NAME)
final PasswordHistoryService passwordHistoryService) {
return new RestPasswordManagementService(passwordManagementCipherExecutor,
casProperties,
buildRestTemplateBuilder(restTemplateBuilder, casProperties),
passwordHistoryService);
return builder.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,18 @@
import org.apereo.cas.pm.PasswordHistoryService;
import org.apereo.cas.pm.PasswordManagementQuery;
import org.apereo.cas.pm.impl.BasePasswordManagementService;
import org.apereo.cas.util.CollectionUtils;
import org.apereo.cas.util.crypto.CipherExecutor;

import lombok.val;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.web.client.RestTemplate;

import org.springframework.web.util.UriComponentsBuilder;
import java.io.Serializable;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

Expand All @@ -43,23 +42,20 @@ public RestPasswordManagementService(final CipherExecutor<Serializable, String>
@Override
public boolean changeInternal(final PasswordChangeRequest bean) {
val rest = casProperties.getAuthn().getPm().getRest();

if (StringUtils.isBlank(rest.getEndpointUrlChange())) {
return false;
}

val headers = new HttpHeaders();
headers.setAccept(CollectionUtils.wrap(MediaType.APPLICATION_JSON));
headers.put(rest.getFieldNameUser(), CollectionUtils.wrap(bean.getUsername()));
headers.put(rest.getFieldNamePassword(), CollectionUtils.wrap(bean.toPassword()));
val body = new HashMap<>();
body.put(rest.getFieldNameUser(), bean.getUsername());
body.put(rest.getFieldNamePassword(), bean.toPassword());
if (bean.getCurrentPassword() != null) {
headers.put(rest.getFieldNamePasswordOld(), CollectionUtils.wrap(bean.toCurrentPassword()));
body.put(rest.getFieldNamePasswordOld(), bean.toCurrentPassword());
}

val entity = new HttpEntity<>(headers);
val entity = new HttpEntity<>(body);
val result = restTemplate.exchange(rest.getEndpointUrlChange(), HttpMethod.POST, entity, Boolean.class);
return result.getStatusCode().value() == HttpStatus.OK.value() && result.hasBody()
&& Objects.requireNonNull(result.getBody());
&& Objects.requireNonNull(result.getBody());
}

@Override
Expand All @@ -68,12 +64,10 @@ public String findUsername(final PasswordManagementQuery query) {
if (StringUtils.isBlank(rest.getEndpointUrlUser())) {
return null;
}

val headers = new HttpHeaders();
headers.setAccept(CollectionUtils.wrap(MediaType.APPLICATION_JSON));
headers.put("email", CollectionUtils.wrap(query.getUsername()));
val entity = new HttpEntity<>(headers);
val result = restTemplate.exchange(rest.getEndpointUrlUser(), HttpMethod.GET, entity, String.class);
val url = UriComponentsBuilder.fromUriString(rest.getEndpointUrlUser())
.queryParam("email", query.getUsername()).build().toUriString();
val request = new RequestEntity<>(HttpMethod.GET, URI.create(url));
val result = restTemplate.exchange(request, String.class);

if (result.getStatusCode().value() == HttpStatus.OK.value() && result.hasBody()) {
return result.getBody();
Expand All @@ -88,11 +82,10 @@ public String findEmail(final PasswordManagementQuery query) {
return null;
}

val headers = new HttpHeaders();
headers.setAccept(CollectionUtils.wrap(MediaType.APPLICATION_JSON));
headers.put("username", CollectionUtils.wrap(query.getUsername()));
val entity = new HttpEntity<>(headers);
val result = restTemplate.exchange(rest.getEndpointUrlEmail(), HttpMethod.GET, entity, String.class);
val url = UriComponentsBuilder.fromUriString(rest.getEndpointUrlEmail())
.queryParam("username", query.getUsername()).build().toUriString();
val request = new RequestEntity<>(HttpMethod.GET, URI.create(url));
val result = restTemplate.exchange(request, String.class);

if (result.getStatusCode().value() == HttpStatus.OK.value() && result.hasBody()) {
return result.getBody();
Expand All @@ -106,12 +99,10 @@ public String findPhone(final PasswordManagementQuery query) {
if (StringUtils.isBlank(rest.getEndpointUrlPhone())) {
return null;
}

val headers = new HttpHeaders();
headers.setAccept(CollectionUtils.wrap(MediaType.APPLICATION_JSON));
headers.put("username", CollectionUtils.wrap(query.getUsername()));
val entity = new HttpEntity<>(headers);
val result = restTemplate.exchange(rest.getEndpointUrlPhone(), HttpMethod.GET, entity, String.class);
val url = UriComponentsBuilder.fromUriString(rest.getEndpointUrlPhone())
.queryParam("username", query.getUsername()).build().toUriString();
val request = new RequestEntity<>(HttpMethod.GET, URI.create(url));
val result = restTemplate.exchange(request, String.class);

if (result.getStatusCode().value() == HttpStatus.OK.value() && result.hasBody()) {
return result.getBody();
Expand All @@ -125,12 +116,11 @@ public Map<String, String> getSecurityQuestions(final PasswordManagementQuery qu
if (StringUtils.isBlank(rest.getEndpointUrlSecurityQuestions())) {
return null;
}
val headers = new HttpHeaders();
headers.setAccept(CollectionUtils.wrap(MediaType.APPLICATION_JSON));
headers.put("username", CollectionUtils.wrap(query.getUsername()));
val entity = new HttpEntity<>(headers);
val result = restTemplate.exchange(rest.getEndpointUrlSecurityQuestions(),
HttpMethod.GET, entity, Map.class);

val url = UriComponentsBuilder.fromUriString(rest.getEndpointUrlSecurityQuestions())
.queryParam("username", query.getUsername()).build().toUriString();
val request = new RequestEntity<>(HttpMethod.GET, URI.create(url));
val result = restTemplate.exchange(request, Map.class);

if (result.getStatusCode().value() == HttpStatus.OK.value() && result.hasBody()) {
return result.getBody();
Expand All @@ -142,11 +132,10 @@ public Map<String, String> getSecurityQuestions(final PasswordManagementQuery qu
public void updateSecurityQuestions(final PasswordManagementQuery query) {
val rest = casProperties.getAuthn().getPm().getRest();
if (StringUtils.isNotBlank(rest.getEndpointUrlSecurityQuestions())) {
val headers = new HttpHeaders();
headers.setAccept(CollectionUtils.wrap(MediaType.APPLICATION_JSON));
headers.put("username", CollectionUtils.wrap(query.getUsername()));
val entity = new HttpEntity<>(query.getSecurityQuestions(), headers);
restTemplate.exchange(rest.getEndpointUrlSecurityQuestions(), HttpMethod.POST, entity, Integer.class);
val url = UriComponentsBuilder.fromUriString(rest.getEndpointUrlSecurityQuestions())
.queryParam("username", query.getUsername()).build().toUriString();
val entity = new HttpEntity<>(query.getSecurityQuestions());
restTemplate.exchange(url, HttpMethod.POST, entity, Boolean.class);
}
}

Expand All @@ -155,12 +144,10 @@ public boolean unlockAccount(final Credential credential) {
val rest = casProperties.getAuthn().getPm().getRest();
var result = true;
if (StringUtils.isNotBlank(rest.getEndpointUrlAccountUnlock())) {
val headers = new HttpHeaders();
headers.setAccept(CollectionUtils.wrap(MediaType.APPLICATION_JSON));
headers.put("username", CollectionUtils.wrap(credential.getId()));
val url = StringUtils.appendIfMissing(rest.getEndpointUrlAccountUnlock(), "/").concat(credential.getId());
result = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(headers), Boolean.class)
.getStatusCode().is2xxSuccessful();
val url = UriComponentsBuilder.fromUriString(rest.getEndpointUrlAccountUnlock())
.queryParam("username", credential.getId()).build().toUriString();
val request = new RequestEntity<>(HttpMethod.POST, URI.create(url));
result = restTemplate.exchange(request, Boolean.class).getStatusCode().is2xxSuccessful();
}
return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ void verifyEmailFound() throws Throwable {
"cas.authn.pm.rest.endpoint-url-phone=http://localhost:9092",
"cas.authn.pm.rest.endpoint-url-account-unlock=http://localhost:9092",
"cas.authn.pm.rest.endpoint-username=username",
"cas.authn.pm.rest.endpoint-password=password"
"cas.authn.pm.rest.endpoint-password=password",
"cas.authn.pm.rest.headers.header1=value1"
})
public class BasicOperations {
@Autowired
Expand Down Expand Up @@ -221,6 +222,7 @@ void verifyUpdateSecurityQuestions() {
rest.setEndpointUrlChange("http://localhost:9308");
rest.setEndpointUrlSecurityQuestions("http://localhost:9308");
rest.setEndpointUrlEmail("http://localhost:9308");
rest.getHeaders().put("header1", "value1");
val passwordService = getRestPasswordManagementService(props);

assertDoesNotThrow(() -> passwordService.updateSecurityQuestions(query));
Expand Down
Loading

0 comments on commit f818cfd

Please sign in to comment.