Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cognito): user pool verification and invitation messages #6282

Merged
merged 3 commits into from
Feb 20, 2020
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(cognito): user pool verification and invitation messages
Niranjan Jayakar committed Feb 14, 2020
commit a3d69dca9d3e534aa98bbda4e448828cc1b7564f
93 changes: 93 additions & 0 deletions packages/@aws-cdk/aws-cognito/README.md
Original file line number Diff line number Diff line change
@@ -15,4 +15,97 @@
---
<!--END STABILITY BANNER-->

[Amazon Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html) provides
authentication, authorization, and user management for your web and mobile apps. Your users can sign in directly with a
user name and password, or through a third party such as Facebook, Amazon, Google or Apple.

The two main components of Amazon Cognito are [user
pools](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html) and [identity
pools](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-identity.html). User pools are user directories
that provide sign-up and sign-in options for your app users. Identity pools enable you to grant your users access to
other AWS services.

This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project.

## User Pools

User pools allow creating and managing your own directory of users that can sign up and sign in. They enable easy
integration with social identity providers such as Facebook, Google, Amazon, Microsoft Active Directory, etc. through
SAML.

Using the CDK, a new user pool can be created as part of the stack using the construct's constructor. You may specify
the `userPoolName` to give your own identifier to the user pool. If not, CloudFormation will generate a name.

```ts
new UserPool(this, 'myuserpool', {
userPoolName: 'myawesomeapp-userpool',
});
```

### Sign Up

Users can either be signed up by the app's administrators or can sign themselves up. Once a user has signed up, their
account needs to be confirmed. Cognito provides several ways to sign users up and confirm their accounts. Learn more
about [user sign up here](https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html).

When a user signs up, email and SMS messages are used to verify their account and contact methods. The following code
snippet configures a user pool with properties relevant to these verification messages -

```ts
new UserPool(this, 'myuserpool', {
// ...
selfSignUpEnabled: true,
userVerification: {
emailSubject: 'Verify your email for our awesome app!',
emailBody: 'Hello {username}, Thanks for signing up to our awesome app! Your verification code is {####}',
emailStyle: VerificationEmailStyle.CODE,
smsMessage: 'Hello {username}, Thanks for signing up to our awesome app! Your verification code is {####}',
}
});
```

By default, self sign up is disabled. Learn more about [email and SMS verification messages
here](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-message-customizations.html).

Besides users signing themselves up, an administrator of any user pool can sign users up. The user then receives an
invitation to join the user pool. The following code snippet configures a user pool with properties relevant to the
invitation messages -

```ts
new UserPool(this, 'myuserpool', {
// ...
userInvitation: {
emailSubject: 'Invite to join our awesome app!',
emailBody: 'Hello {username}, you have been invited to join our awesome app! Your temporary password is {####}',
smsMessage: 'Your temporary password for our awesome app is {####}'
}
});
```

All email subjects, bodies and SMS messages for both invitation and verification support Cognito's message templating.
Learn more about [message templates
here](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-message-templates.html).

### Security

Cognito sends various messages to its users via SMS, for different actions, ranging from account verification to
marketing. In order to send SMS messages, Cognito needs an IAM role that it can assume, with permissions that allow it
to send SMS messages. By default, CDK will create this IAM role but can also be explicily specified to an existing IAM
role using the `smsRole` property.

```ts
import { Role } from '@aws-cdk/aws-iam';

const poolSmsRole = new Role(this, 'userpoolsmsrole', { /* ... */ });

new UserPool(this, 'myuserpool', {
// ...
smsRole: poolSmsRole,
smsRoleExternalId: 'c87467be-4f34-11ea-b77f-2e728ce88125'
});
```

When the `smsRole` property is specified, the `smsRoleExternalId` may also be specified. The value of
`smsRoleExternalId` will be used as the `sts:ExternalId` when the Cognito service assumes the role. In turn, the role's
assume role policy should be configured to accept this value as the ExternalId. Learn more about [ExternalId
here](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html).
184 changes: 181 additions & 3 deletions packages/@aws-cdk/aws-cognito/lib/user-pool.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as iam from '@aws-cdk/aws-iam';
import { IRole, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from '@aws-cdk/aws-iam';
import * as lambda from '@aws-cdk/aws-lambda';
import { Construct, IResource, Lazy, Resource } from '@aws-cdk/core';
import { CfnUserPool } from './cognito.generated';
@@ -212,6 +212,78 @@ export interface UserPoolTriggers {
[trigger: string]: lambda.IFunction | undefined;
}

/**
* The email verification style
*/
export enum VerificationEmailStyle {
/** Verify email via code */
CODE = 'CONFIRM_WITH_CODE',
/** Verify email via link */
LINK = 'CONFIRM_WITH_LINK',
}

/**
* User pool configuration for user self sign up.
*/
export interface UserVerificationConfig {
/**
* The email subject template for the verification email sent to the user upon sign up.
* See https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-message-templates.html to
* learn more about message templates.
* @default 'Verify your new account'
*/
readonly emailSubject?: string;

/**
* The email body template for the verification email sent to the user upon sign up.
* See https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-message-templates.html to
* learn more about message templates.
* @default 'Hello {username}, Your verification code is {####}'
*/
readonly emailBody?: string;

/**
* Emails can be verified either using a code or a link.
* Learn more at https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-email-verification-message-customization.html
* @default VerificationEmailStyle.CODE
*/
readonly emailStyle?: VerificationEmailStyle;

/**
* The message template for the verification SMS sent to the user upon sign up.
* See https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-message-templates.html to
* learn more about message templates.
* @default 'The verification code to your new account is {####}'
*/
readonly smsMessage?: string;
}

/**
* User pool configuration when administrators sign users up.
*/
export interface UserInvitationConfig {
/**
* The template to the email subject that is sent to the user when an administrator signs them up to the user pool.
* @default 'Your temporary password'
*/
readonly emailSubject?: string;

/**
* The template to the email body that is sent to the user when an administrator signs them up to the user pool.
* @default 'Your username is {username} and temporary password is {####}.'
*/
readonly emailBody?: string;

/**
* The template to the SMS message that is sent to the user when an administrator signs them up to the user pool.
* @default 'Your username is {username} and temporary password is {####}'
*/
readonly smsMessage?: string;
}

/**
* Props for the UserPool construct
*/
export interface UserPoolProps {
/**
* Name of the user pool
@@ -220,6 +292,40 @@ export interface UserPoolProps {
*/
readonly userPoolName?: string;

/**
* Whether self sign up should be enabled. This can be further configured via the `selfSignUp` property.
* @default false
*/
readonly selfSignUpEnabled?: boolean;

/**
* Configuration around users signing themselves up to the user pool.
* Enable or disable self sign-up via the `selfSignUpEnabled` property.
* @default - see defaults in UserVerificationConfig
*/
readonly userVerification?: UserVerificationConfig;

/**
* Configuration around admins signing up users into a user pool.
* @default - see defaults in UserInvitationConfig
*/
readonly userInvitation?: UserInvitationConfig;

/**
* The IAM role that Cognito will assume while sending SMS messages.
* @default - a new IAM role is created
*/
readonly smsRole?: IRole;

/**
* The 'ExternalId' that Cognito service must using when assuming the `smsRole`, if the role is restricted with an 'sts:ExternalId' conditional.
* Learn more about ExternalId here - https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html
*
* This property will be ignored if `smsRole` is not specified.
* @default - No external id will be configured
*/
readonly smsRoleExternalId?: string;

/**
* Method used for user registration & sign in.
* Allows either username with aliases OR sign in with email, phone, or both.
@@ -400,12 +506,47 @@ export class UserPool extends Resource implements IUserPool {
}
}

const emailVerificationSubject = props.userVerification?.emailSubject ?? 'Verify your new account';
MrArnoldPalmer marked this conversation as resolved.
Show resolved Hide resolved
const emailVerificationMessage = props.userVerification?.emailBody ?? 'Hello {username}, Your verification code is {####}';
const smsVerificationMessage = props.userVerification?.smsMessage ?? 'The verification code to your new account is {####}';

const defaultEmailOption = props.userVerification?.emailStyle ?? VerificationEmailStyle.CODE;
const verificationMessageTemplate: CfnUserPool.VerificationMessageTemplateProperty =
(defaultEmailOption === VerificationEmailStyle.CODE) ? {
defaultEmailOption,
emailMessage: emailVerificationMessage,
emailSubject: emailVerificationSubject,
smsMessage: smsVerificationMessage,
} : {
defaultEmailOption,
emailMessageByLink: emailVerificationMessage,
emailSubjectByLink: emailVerificationSubject,
smsMessage: smsVerificationMessage
};

const inviteMessageTemplate: CfnUserPool.InviteMessageTemplateProperty = {
emailMessage: props.userInvitation?.emailBody,
emailSubject: props.userInvitation?.emailSubject,
smsMessage: props.userInvitation?.smsMessage,
};
const selfSignUpEnabled = props.selfSignUpEnabled !== undefined ? props.selfSignUpEnabled : false;
const adminCreateUserConfig: CfnUserPool.AdminCreateUserConfigProperty = {
allowAdminCreateUserOnly: !selfSignUpEnabled,
inviteMessageTemplate: props.userInvitation !== undefined ? inviteMessageTemplate : undefined,
};

const userPool = new CfnUserPool(this, 'Resource', {
userPoolName: props.userPoolName,
usernameAttributes,
aliasAttributes,
autoVerifiedAttributes: props.autoVerifiedAttributes,
lambdaConfig: Lazy.anyValue({ produce: () => this.triggers })
lambdaConfig: Lazy.anyValue({ produce: () => this.triggers }),
smsConfiguration: this.smsConfiguration(props),
adminCreateUserConfig,
emailVerificationMessage,
emailVerificationSubject,
smsVerificationMessage,
verificationMessageTemplate,
});

this.userPoolId = userPool.ref;
@@ -528,8 +669,45 @@ export class UserPool extends Resource implements IUserPool {
private addLambdaPermission(fn: lambda.IFunction, name: string): void {
const normalize = name.charAt(0).toUpperCase() + name.slice(1);
fn.addPermission(`${normalize}Cognito`, {
principal: new iam.ServicePrincipal('cognito-idp.amazonaws.com'),
principal: new ServicePrincipal('cognito-idp.amazonaws.com'),
sourceArn: this.userPoolArn
});
}

private smsConfiguration(props: UserPoolProps): CfnUserPool.SmsConfigurationProperty {
if (props.smsRole) {
return {
snsCallerArn: props.smsRole.roleArn,
externalId: props.smsRoleExternalId
};
} else {
const smsRoleExternalId = this.node.uniqueId.substr(0, 1223); // sts:ExternalId max length of 1224
const smsRole = props.smsRole ?? new Role(this, 'smsRole', {
assumedBy: new ServicePrincipal('cognito-idp.amazonaws.com', {
conditions: {
StringEquals: { 'sts:ExternalId': smsRoleExternalId }
}
}),
inlinePolicies: {
/*
* The UserPool is very particular that it must contain an 'sns:Publish' action as an inline policy.
* Ideally, a conditional that restricts this action to 'sms' protocol needs to be attached, but the UserPool deployment fails validation.
* Seems like a case of being excessively strict.
*/
'sns-publish': new PolicyDocument({
statements: [
new PolicyStatement({
actions: [ 'sns:Publish' ],
resources: [ '*' ],
})
]
})
}
});
return {
externalId: smsRoleExternalId,
snsCallerArn: smsRole.roleArn
};
}
}
}
4 changes: 2 additions & 2 deletions packages/@aws-cdk/aws-cognito/package.json
Original file line number Diff line number Diff line change
@@ -65,6 +65,7 @@
"@aws-cdk/assert": "1.24.0",
"@types/nodeunit": "^0.0.30",
"cdk-build-tools": "1.24.0",
"cdk-integ-tools": "1.24.0",
"cfn2ts": "1.24.0",
"jest": "^24.9.0",
"nodeunit": "^0.11.3",
@@ -104,10 +105,9 @@
"docs-public-apis:@aws-cdk/aws-cognito.UserPoolClient.userPoolClientClientSecret",
"docs-public-apis:@aws-cdk/aws-cognito.UserPoolClient.userPoolClientId",
"docs-public-apis:@aws-cdk/aws-cognito.UserPoolClient.userPoolClientName",
"docs-public-apis:@aws-cdk/aws-cognito.UserPoolProps",
"docs-public-apis:@aws-cdk/aws-cognito.UserPoolAttributes",
"docs-public-apis:@aws-cdk/aws-cognito.UserPoolClientProps"
]
},
"stability": "experimental"
}
}
69 changes: 69 additions & 0 deletions packages/@aws-cdk/aws-cognito/test/integ.user-pool.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"Resources": {
"myuserpoolsmsRole0E16FDD9": {
"Type": "AWS::IAM::Role",
"Properties": {
"AssumeRolePolicyDocument": {
"Statement": [
{
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "integuserpoolmyuserpoolDA38443C"
}
},
"Effect": "Allow",
"Principal": {
"Service": "cognito-idp.amazonaws.com"
}
}
],
"Version": "2012-10-17"
},
"Policies": [
{
"PolicyDocument": {
"Statement": [
{
"Action": "sns:Publish",
"Effect": "Allow",
"Resource": "*"
}
],
"Version": "2012-10-17"
},
"PolicyName": "sns-publish"
}
]
}
},
"myuserpool01998219": {
"Type": "AWS::Cognito::UserPool",
"Properties": {
"AdminCreateUserConfig": {
"AllowAdminCreateUserOnly": true
},
"EmailVerificationMessage": "Hello {username}, Your verification code is {####}",
"EmailVerificationSubject": "Verify your new account",
"LambdaConfig": {},
"SmsConfiguration": {
"ExternalId": "integuserpoolmyuserpoolDA38443C",
"SnsCallerArn": {
"Fn::GetAtt": [
"myuserpoolsmsRole0E16FDD9",
"Arn"
]
}
},
"SmsVerificationMessage": "The verification code to your new account is {####}",
"UserPoolName": "MyUserPool",
"VerificationMessageTemplate": {
"DefaultEmailOption": "CONFIRM_WITH_CODE",
"EmailMessage": "Hello {username}, Your verification code is {####}",
"EmailSubject": "Verify your new account",
"SmsMessage": "The verification code to your new account is {####}"
}
}
}
}
}
9 changes: 9 additions & 0 deletions packages/@aws-cdk/aws-cognito/test/integ.user-pool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { App, Stack } from '@aws-cdk/core';
import { UserPool } from '../lib';

const app = new App();
const stack = new Stack(app, 'integ-user-pool');

new UserPool(stack, 'myuserpool', {
userPoolName: 'MyUserPool',
});
145 changes: 140 additions & 5 deletions packages/@aws-cdk/aws-cognito/test/user-pool.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,156 @@
import '@aws-cdk/assert/jest';
import { ABSENT } from '@aws-cdk/assert/lib/assertions/have-resource';
import { Role } from '@aws-cdk/aws-iam';
import * as lambda from '@aws-cdk/aws-lambda';
import { Stack, Tag } from '@aws-cdk/core';
import { SignInType, UserPool, UserPoolAttribute } from '../lib';
import { SignInType, UserPool, UserPoolAttribute, VerificationEmailStyle } from '../lib';

describe('User Pool', () => {
test('default setup', () => {
// GIVEN
const stack = new Stack();

// WHEN
new UserPool(stack, 'Pool');

// THEN
expect(stack).toHaveResource('AWS::Cognito::UserPool', {
AdminCreateUserConfig: {
AllowAdminCreateUserOnly: true,
InviteMessageTemplate: ABSENT
},
EmailVerificationMessage: 'Hello {username}, Your verification code is {####}',
EmailVerificationSubject: 'Verify your new account',
SmsVerificationMessage: 'The verification code to your new account is {####}',
VerificationMessageTemplate: {
DefaultEmailOption: 'CONFIRM_WITH_CODE',
EmailMessage: 'Hello {username}, Your verification code is {####}',
EmailSubject: 'Verify your new account',
SmsMessage: 'The verification code to your new account is {####}',
},
SmsConfiguration: {
SnsCallerArn: {
'Fn::GetAtt': [ 'PoolsmsRoleC3352CE6', 'Arn' ],
},
ExternalId: 'Pool'
}
});

expect(stack).toHaveResourceLike('AWS::IAM::Role', {
AssumeRolePolicyDocument: {
Statement: [
{
Action: 'sts:AssumeRole',
Condition: {
StringEquals: {
'sts:ExternalId': 'Pool'
}
},
Effect: 'Allow',
Principal: {
Service: 'cognito-idp.amazonaws.com'
}
}
]
},
Policies: [
{
PolicyDocument: {
Statement: [
{
Action: 'sns:Publish',
Effect: 'Allow',
Resource: '*'
}
]
}
}
]
});
});

test('self sign up option is correctly configured', () => {
// GIVEN
const stack = new Stack();

// WHEN
new UserPool(stack, 'Pool', {
userPoolName: 'myPool',
selfSignUpEnabled: true
});

// THEN
expect(stack).toHaveResource('AWS::Cognito::UserPool', {
AdminCreateUserConfig: {
AllowAdminCreateUserOnly: false
}
});
});

test('email verification via link is configured correctly', () => {
// GIVEN
const stack = new Stack();

// WHEN
new UserPool(stack, 'Pool', {
userVerification: {
emailStyle: VerificationEmailStyle.LINK
}
});

// THEN
expect(stack).toHaveResourceLike('AWS::Cognito::UserPool', {
UserPoolName: 'myPool'
EmailVerificationMessage: 'Hello {username}, Your verification code is {####}',
EmailVerificationSubject: 'Verify your new account',
VerificationMessageTemplate: {
DefaultEmailOption: 'CONFIRM_WITH_LINK',
EmailMessageByLink: 'Hello {username}, Your verification code is {####}',
EmailSubjectByLink: 'Verify your new account',
}
});
}),

test('user invitation messages are configured correctly', () => {
// GIVEN
const stack = new Stack();

// WHEN
new UserPool(stack, 'Pool', {
userInvitation: {
emailBody: 'invitation email body',
emailSubject: 'invitation email subject',
smsMessage: 'invitation sms'
}
});

// THEN
expect(stack).toHaveResourceLike('AWS::Cognito::UserPool', {
AdminCreateUserConfig: {
InviteMessageTemplate: {
EmailMessage: 'invitation email body',
EmailSubject: 'invitation email subject',
SMSMessage: 'invitation sms'
}
}
});
});

test('smsRole property is recognized', () => {
// GIVEN
const stack = new Stack();
const role = Role.fromRoleArn(stack, 'smsRole', 'arn:aws:iam::664773442901:role/sms-role');

// WHEN
new UserPool(stack, 'Pool', {
smsRole: role,
smsRoleExternalId: 'test-external-id'
});

// THEN
expect(stack).toHaveResourceLike('AWS::Cognito::UserPool', {
SmsConfiguration: {
ExternalId: 'test-external-id',
SnsCallerArn: role.roleArn
}
});
});

@@ -27,13 +162,13 @@ describe('User Pool', () => {
const pool = new UserPool(stack, 'Pool', {
userPoolName: 'myPool',
});
Tag.add(pool, "PoolTag", "PoolParty");
Tag.add(pool, 'PoolTag', 'PoolParty');

// THEN
expect(stack).toHaveResourceLike('AWS::Cognito::UserPool', {
UserPoolName: 'myPool',
UserPoolTags: {
PoolTag: "PoolParty",
PoolTag: 'PoolParty',
}
});
});