Skip to content

Commit

Permalink
Add verification link API
Browse files Browse the repository at this point in the history
[#105489788] https://www.pivotaltracker.com/story/show/105489788

Signed-off-by: Jonathan Lo <jlo@us.ibm.com>
  • Loading branch information
Paul Warren authored and jlo committed Oct 15, 2015
1 parent f425688 commit d2ef864
Show file tree
Hide file tree
Showing 10 changed files with 499 additions and 27 deletions.
40 changes: 37 additions & 3 deletions docs/UAA-APIs.rst
Expand Up @@ -37,7 +37,7 @@ Here is a summary of the different scopes that are known to the UAA.
* **clients.secret** - ``/oauth/clients/*/secret`` endpoint. Scope required to change the password of a client. Considered an admin scope.
* **scim.write** - Admin write access to all SCIM endpoints, ``/Users``, ``/Groups/``.
* **scim.read** - Admin read access to all SCIM endpoints, ``/Users``, ``/Groups/``.
* **scim.create** - Reduced scope to be able to create a user using ``POST /Users`` (and verify their account using ``GET /Users/{id}/verify``) but not be able to modify, read or delete users.
* **scim.create** - Reduced scope to be able to create a user using ``POST /Users`` (get verification links ``GET /Users/{id}/verify-link`` or verify their account using ``GET /Users/{id}/verify``) but not be able to modify, read or delete users.
* **scim.userids** - ``/ids/Users`` - Required to convert a username+origin to a user ID and vice versa.
* **scim.zones** - Limited scope that only allows adding/removing a user to/from `zone management groups`_ under the path /Groups/zones
* **scim.invite** - Scope required by a client in order to participate in invitations using the ``/invite_users`` endpoint.
Expand Down Expand Up @@ -1542,6 +1542,42 @@ See `SCIM - Changing Password <http://www.simplecloud.info/specs/draft-scim-api-

.. note:: SCIM specifies that a password change is a PATCH, but since this isn't supported by many clients, we have used PUT. SCIM offers the option to use POST with a header override - if clients want to send `X-HTTP-Method-Override` they can ask us to add support for that.

Verify User Links: ``GET /Users/{id}/verify-link``
---------------------------------------


* Request: ``GET /Users/{id}/verify-link``

* Request Parameters::

client_id; the id of the client requesting the verification link (optional)
redirect_uri; the eventual URI that will be redirected when the user verifies using the link

* Request Headers: Authorization header containing an `OAuth2`_ bearer token with::

scope = scim.create

* Request Body::

Host: example.com
Accept: application/json
Authorization: Bearer h480djs93hd8

* Response Body::

{
"verify_link": "http://myuaa.cloudfoundry.com/verify_user?code=yuT6rd"
}

* Response Codes::

200 - Success
400 - Bad Request
401 - Unauthorized
403 - Forbidden
404 - Not Found; Scim Resource Not Found
405 - Method Not Allowed; User Already Verified

Verify User: ``GET /Users/{id}/verify``
---------------------------------------

Expand Down Expand Up @@ -1572,8 +1608,6 @@ Verify User: ``GET /Users/{id}/verify``
401 - Unauthorized
404 - Not found

.. note:: SCIM specifies that a password change is a PATCH, but since this isn't supported by many clients, we have used PUT. SCIM offers the option to use POST with a header override - if clients want to send `X-HTTP-Method-Override` they can ask us to add support for that.

Query for Information: ``GET /Users``
---------------------------------------

Expand Down
Expand Up @@ -10,6 +10,7 @@
import org.cloudfoundry.identity.uaa.scim.ScimUser;
import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning;
import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceAlreadyExistsException;
import org.cloudfoundry.identity.uaa.scim.util.ScimUtils;
import org.cloudfoundry.identity.uaa.scim.validate.PasswordValidator;
import org.cloudfoundry.identity.uaa.util.JsonUtils;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
Expand All @@ -24,10 +25,8 @@
import org.thymeleaf.spring4.SpringTemplateEngine;

import java.io.IOException;
import java.sql.Timestamp;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -96,23 +95,12 @@ public void beginActivation(String email, String password, String clientId, Stri
}

private void generateAndSendCode(String email, String clientId, String subject, String userId, String redirectUri) throws IOException {
Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + (60 * 60 * 1000)); // 1 hour
ExpiringCode expiringCodeForPost = getExpiringCode(userId, clientId, expiresAt, redirectUri);
ExpiringCode expiringCode = codeStore.generateCode(expiringCodeForPost.getData(), expiringCodeForPost.getExpiresAt());
ExpiringCode expiringCode = ScimUtils.getExpiringCode(codeStore, userId, email, clientId, redirectUri);
String htmlContent = getEmailHtml(expiringCode.getCode(), email);

messageService.sendMessage(email, MessageType.CREATE_ACCOUNT_CONFIRMATION, subject, htmlContent);
}

private ExpiringCode getExpiringCode(String userId, String clientId, Timestamp expiresAt, String redirectUri) throws IOException {
Map<String, String> codeData = new HashMap<>();
codeData.put("user_id", userId);
codeData.put("client_id", clientId);
codeData.put("redirect_uri", redirectUri);
String codeDataString = JsonUtils.writeValueAsString(codeData);
return new ExpiringCode(null, expiresAt, codeDataString);
}

@Override
public AccountCreationResponse completeActivation(String code) throws IOException {

Expand Down Expand Up @@ -192,7 +180,7 @@ private String getSubjectText() {
}

private String getEmailHtml(String code, String email) {
String accountsUrl = uaaUrlUtils.getUaaUrl("/verify_user");
String accountsUrl = ScimUtils.getVerificationURL(null, null).toString();

final Context ctx = new Context();
if (IdentityZoneHolder.isUaa()) {
Expand Down
Expand Up @@ -111,6 +111,7 @@ public void testBeginActivation() throws Exception {
String data = setUpForSuccess(redirectUri);
when(scimUserProvisioning.createUser(any(ScimUser.class), anyString())).thenReturn(user);
when(codeStore.generateCode(eq(data), any(Timestamp.class))).thenReturn(code);

emailAccountCreationService.beginActivation("user@example.com", "password", "login", redirectUri);

String emailBody = captorEmailBody("Activate your Pivotal ID");
Expand Down Expand Up @@ -355,8 +356,11 @@ private String setUpForSuccess(String userId, String redirectUri) throws Excepti
Timestamp ts = new Timestamp(System.currentTimeMillis() + (60 * 60 * 1000)); // 1 hour
Map<String, Object> data = new HashMap<>();
data.put("user_id", userId);
data.put("email", "user@example.com");
data.put("client_id", "login");
data.put("redirect_uri", redirectUri);
if (redirectUri != null) {
data.put("redirect_uri", redirectUri);
}

code = new ExpiringCode("the_secret_code", ts, JsonUtils.writeValueAsString(data));

Expand Down
Expand Up @@ -219,6 +219,7 @@ public String getValue() {
}

public void setValue(String value) {
Assert.notNull(value);
this.value = value;
}

Expand Down Expand Up @@ -556,6 +557,8 @@ public String getPrimaryEmail() {
}

public void setPrimaryEmail(String value) {
Assert.notNull(value);

Email newPrimaryEmail = new Email();
newPrimaryEmail.setPrimary(true);
newPrimaryEmail.setValue(value);
Expand Down
Expand Up @@ -14,6 +14,8 @@

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.cloudfoundry.identity.uaa.codestore.ExpiringCode;
import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore;
import org.cloudfoundry.identity.uaa.error.ConvertingExceptionView;
import org.cloudfoundry.identity.uaa.error.ExceptionReport;
import org.cloudfoundry.identity.uaa.oauth.approval.Approval;
Expand All @@ -30,10 +32,11 @@
import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning;
import org.cloudfoundry.identity.uaa.scim.exception.ScimException;
import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceConflictException;
import org.cloudfoundry.identity.uaa.scim.exception.UserAlreadyVerifiedException;
import org.cloudfoundry.identity.uaa.scim.util.ScimUtils;
import org.cloudfoundry.identity.uaa.scim.validate.PasswordValidator;
import org.cloudfoundry.identity.uaa.util.UaaPagingUtils;
import org.cloudfoundry.identity.uaa.util.UaaStringUtils;
import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.expression.spel.SpelEvaluationException;
Expand All @@ -44,7 +47,10 @@
import org.springframework.jmx.export.annotation.ManagedMetric;
import org.springframework.jmx.export.annotation.ManagedResource;
import org.springframework.jmx.support.MetricType;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.codec.Hex;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
Expand Down Expand Up @@ -118,6 +124,8 @@ public class ScimUserEndpoints implements InitializingBean {

private PasswordValidator passwordValidator;

private ExpiringCodeStore codeStore;

/**
* Set the message body converters to use.
* <p>
Expand Down Expand Up @@ -227,6 +235,36 @@ public ScimUser deleteUser(@PathVariable String userId,
return user;
}

@RequestMapping(value = "/Users/{userId}/verify-link", method = RequestMethod.GET)
@ResponseBody
public ResponseEntity<VerificationResponse> getUserVerificationLink(@PathVariable String userId,
@RequestParam(value="client_id", required = false) String clientId,
@RequestParam(value="redirect_uri") String redirectUri) {

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof OAuth2Authentication) {
OAuth2Authentication oAuth2Authentication = (OAuth2Authentication)authentication;

if (clientId==null) {
clientId = oAuth2Authentication.getOAuth2Request().getClientId();
}
}

VerificationResponse responseBody = new VerificationResponse();

ScimUser user = dao.retrieve(userId);
if (user.isVerified()) {
throw new UserAlreadyVerifiedException();
}

codeStore.retrieveLatest(user.getPrimaryEmail(), clientId);

ExpiringCode expiringCode = ScimUtils.getExpiringCode(codeStore, userId, user.getPrimaryEmail(), clientId, redirectUri);
responseBody.setVerifyLink(ScimUtils.getVerificationURL(expiringCode, user.getPrimaryEmail()));

return new ResponseEntity<>(responseBody, HttpStatus.OK);
}

@RequestMapping(value = "/Users/{userId}/verify", method = RequestMethod.GET)
@ResponseBody
public ScimUser verifyUser(@PathVariable String userId,
Expand Down Expand Up @@ -411,4 +449,8 @@ public void setScimUserResourceMonitor(ResourceMonitor<ScimUser> scimUserResourc
public void setPasswordValidator(PasswordValidator passwordValidator) {
this.passwordValidator = passwordValidator;
}

public void setCodeStore(ExpiringCodeStore codeStore) {
this.codeStore = codeStore;
}
}
@@ -0,0 +1,29 @@
/*******************************************************************************
* Cloud Foundry
* Copyright (c) [2009-2015] Pivotal Software, Inc. All Rights Reserved.
* <p>
* This product is licensed to you under the Apache License, Version 2.0 (the "License").
* You may not use this product except in compliance with the License.
* <p>
* This product includes a number of subcomponents with
* separate copyright notices and license terms. Your use of these
* subcomponents is subject to the terms and conditions of the
* subcomponent's license, as noted in the LICENSE file.
*******************************************************************************/
package org.cloudfoundry.identity.uaa.scim.endpoints;

import com.fasterxml.jackson.annotation.JsonProperty;

import java.net.URL;
public class VerificationResponse {
@JsonProperty(value="verify_link")
private URL verifyLink;

public URL getVerifyLink() {
return verifyLink;
}

public void setVerifyLink(URL verifyLink) {
this.verifyLink = verifyLink;
}
}
@@ -0,0 +1,24 @@
package org.cloudfoundry.identity.uaa.scim.exception;

import org.springframework.http.HttpStatus;

/*******************************************************************************
* Cloud Foundry
* Copyright (c) [2009-2015] Pivotal Software, Inc. All Rights Reserved.
* <p>
* This product is licensed to you under the Apache License, Version 2.0 (the "License").
* You may not use this product except in compliance with the License.
* <p>
* This product includes a number of subcomponents with
* separate copyright notices and license terms. Your use of these
* subcomponents is subject to the terms and conditions of the
* subcomponent's license, as noted in the LICENSE file.
*******************************************************************************/
public class UserAlreadyVerifiedException extends ScimException {

public static final String DESC = "This user has already been verified.";

public UserAlreadyVerifiedException() {
super(DESC, HttpStatus.METHOD_NOT_ALLOWED);
}
}
@@ -0,0 +1,104 @@
package org.cloudfoundry.identity.uaa.scim.util;

import org.cloudfoundry.identity.uaa.codestore.ExpiringCode;
import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore;
import org.cloudfoundry.identity.uaa.util.JsonUtils;
import org.cloudfoundry.identity.uaa.util.UaaUrlUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.Assert;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.sql.Timestamp;
import java.util.HashMap;
import java.util.IllegalFormatCodePointException;
import java.util.Map;

/*******************************************************************************
* Cloud Foundry
* Copyright (c) [2009-2015] Pivotal Software, Inc. All Rights Reserved.
* <p>
* This product is licensed to you under the Apache License, Version 2.0 (the "License").
* You may not use this product except in compliance with the License.
* <p>
* This product includes a number of subcomponents with
* separate copyright notices and license terms. Your use of these
* subcomponents is subject to the terms and conditions of the
* subcomponent's license, as noted in the LICENSE file.
*******************************************************************************/
public final class ScimUtils {

private static final Logger logger = LoggerFactory.getLogger(ScimUtils.class);

private ScimUtils() {}

/**
* Generates a 1 hour expiring code. This code is revokable using {@link ExpiringCodeStore#retrieveLatest(String, String)}.
*
* @param codeStore
* the code store to use, must not be null
* @param userId
* the user id that will be included in the code's data, must not be null
* @param email
* the email that will be included in the code's data, must not be null
* @param clientId
* client id that will be included in the code's data, must not be null
* @param redirectUri
* the redirect uri that will be included in the code's data, may be null
* @return
* the expiring code
*/
public static ExpiringCode getExpiringCode(ExpiringCodeStore codeStore, String userId, String email, String clientId, String redirectUri) {
Assert.notNull(codeStore);
Assert.notNull(userId);
Assert.notNull(email);

Map<String, String> codeData = new HashMap<>();
codeData.put("user_id", userId);
codeData.put("email", email);
codeData.put("client_id", clientId);
if (redirectUri != null) {
codeData.put("redirect_uri", redirectUri);
}
String codeDataString = JsonUtils.writeValueAsString(codeData);

Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + (60 * 60 * 1000)); // 1 hour
return codeStore.generateCode(codeDataString, expiresAt);
}

/**
* Returns a verification URL that may be sent to a user.
*
* @param expiringCode
* the expiring code to include on the URL, may be null
* @param email
* the email to include on the URL, may be null
* @return
* the verification URL
*/
public static URL getVerificationURL(ExpiringCode expiringCode, String email) {
String url = "";
try {
url = UaaUrlUtils.getUaaUrl("/verify_user");

if (expiringCode != null) {
url += "?code=" + expiringCode.getCode();
}

if (email != null) {
if (expiringCode != null) {
url += "&email=" + email;
} else {
url += "?email=" + email;
}
}

return new URL(url);
} catch (MalformedURLException mfue) {
logger.error(String.format("Unexpected error creating user verification URL from %s", url), mfue);
}
throw new IllegalStateException();
}
}

0 comments on commit d2ef864

Please sign in to comment.