Related Articles
To ensure that articles get straight to the point, and to avoid bloat, I have broken this process up into multiple articles to make browsing and modular work easier.
This article assumes you already have a user pool in Cognito with a client already configured.
This article also assumes you have a Spring Boot 3 project with Spring Security, and a basic knowledge of how to create Data Access Objects and use hibernate.
In case you need a refresher on those subjects, here are some articles I’ve found that were super helpful for me.
Managing Access Keys for IAM Users
CRUD Repository in Spring Boot 3
Setting Up a Cognito Client
Problem Statement
This was a project I researched and developed for a freelance client. As an end goal, hosting this application will be done on AWS. But while in the staging/development stage of the app, I want to save the client money by self hosting my Spring Boot application.
I am using AWS Cognito for authentication to avoid user migration in the future, and also for its abundant add-ons like email verification, and generous free tier.
I am self-hosting the DEV/Staging environment with a monolithic Spring Boot 3 application that uses hibernate to interface with a PostgreSQL database.
My design and architecture thought process
I need to keep track of user profile data that will be used for complex analysis and must be able to be related to other tables. I also want to avoid using Lambda to sync my tables on sign up or sign in, because that seems excessive and will cost the client a lot of extra money in the long run.
I will use Cognito for the authentication, and keep profile/user data on the spring side in a table. The primary key for users will be the user ID provided with Cognito’s access tokens.
Users should also be able to register via the hosted UI that Cognito provides, so I need to be able to get the “First Name” and “Last Name” from Cognito since that isn’t being done on the spring side.
I will use the Cognito IDP SDK built for Java to use AdminGetUser
, GetUser
, AdminUpdateAttributes
and ListUsers
. This will require an IAM role and IAM policy to grant the Spring Application necessary permissions. And since I am hosting the server outside of AWS, I will need to use AWS Secret Keys to interface with Cognito from Spring.
Here is the dependency for that:
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-cognitoidp</artifactId>
<version>1.12.547</version>
</dependency>
Steps and Development
Configuration & Setup
Firstly create a new IAM user, and create a new group. In my case, I went with “services” as my group name.
I gave the user programmatic access by creating an IAM policy that allows for AdminGetUser
, GetUser
, AdminUpdateAttributes
and ListUsers
and attaching it to the servers group.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "programmaticaccess",
"Effect": "Allow",
"Action": [
"cognito-idp:ListUsers",
"cognito-idp:AdminGetUser",
"cognito-idp:GetUser",
"cognito-idp:AdminUpdateUserAttributes"
],
"Resource": [
"your_resource"
]
}
]
}
Then I generated a Secret key & and Access Key to use on my Spring Boot Application.
aws.accessKeyId=<your_key_id>
aws.secretKey=<your_secret_key>
From there, I configured Spring Security to require all requests to be authenticated, and set the issuer URL to my Cognito User pools client issuer URL. [[Configuring AWS Cognito for Interfacing with Spring and Expo]]
spring.security.oauth2.resourceserver.jwt.issuer-uri=<user_pool_client_issuer_uri>
# Cognito Configuration
cognito.userPoolId=<user_pool_id>
cognito.clientId=<client_id>
cognito.clientSecret=<client_secret>
cognito.region=<your_region>
Now that spring is validating and reading access tokens, we can extract the user ID from the principle. I’ve created a new service for user related methods and included this method there.
@Service("userService")
public class UserServiceImpl implements UserService {
@Override
public String getCurrentId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.getName();
}
}
Then I created a configuration for Cognito that uses the secret and access key to assume the IAM user I created earlier. Now we can send calls to our user pool thanks to the IAM policy we’ve created.
@Getter
@Configuration
public class CognitoUserPoolConfig {
@Value("${cognito.userPoolId}")
private String userPoolId;
@Value("${cognito.clientId}")
private String clientId;
@Value("${cognito.clientSecret}")
private String clientSecret;
@Value("${cognito.region}")
private String region;
@Value("${aws.accessKeyId}")
private String accessKeyId;
@Value("${aws.secretKey}")
private String secretKey;
@Bean
public AWSCognitoIdentityProvider awsCognitoIdentityProvider() {
return AWSCognitoIdentityProviderClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials(accessKeyId, secretKey)))
.withRegion(Regions.fromName(region))
.build();
}
}
I created a new service specifically to interface with AWS Cognito. I’ve added a method to get the user object from the user pool given a user ID, as well as a method to update attributes from the Spring side to Cognito. This will be important for me later because I want to allow users to change their email and such.
@Service("cognitoService")
@AllArgsConstructor
public class CognitoServiceImpl implements CognitoService {
@Autowired
private AWSCognitoIdentityProvider awsCognitoIdentityProvider;
@Autowired
private CognitoUserPoolConfig cognitoUserPoolConfig;
@Override
public Map<String, String> getUserAttributes(String userID) {
AdminGetUserRequest adminGetUserRequest = new AdminGetUserRequest()
.withUserPoolId(cognitoUserPoolConfig.getUserPoolId()) // Use the value
.withUsername(userID);
AdminGetUserResult adminGetUserResult = awsCognitoIdentityProvider.adminGetUser(adminGetUserRequest);
// Convert the list of user attributes to a map
Map<String, String> userAttributes = adminGetUserResult.getUserAttributes().stream()
.collect(Collectors.toMap(AttributeType::getName, AttributeType::getValue));
return userAttributes;
}
@Override
public boolean updateUserAttributes(String userID, Map<String, String> attributes) {
AdminUpdateUserAttributesRequest request = new AdminUpdateUserAttributesRequest()
.withUserPoolId(cognitoUserPoolConfig.getUserPoolId())
.withUsername(userID);
AdminUpdateUserAttributesResult result = awsCognitoIdentityProvider.adminUpdateUserAttributes(request);
return result.getSdkHttpMetadata().getHttpStatusCode() == 200;
}
}
Here is what my User entity looks like:
@Entity
@Table(name = "users")
@Data
public class User {
@Id
private String id;
@Column(name = "username")
private String username;
@Column(name = "email")
private String email;
@Column(name = "email_verified")
private boolean emailVerified;
@Column(name = "first_name")
private String firstName;
@Column(name = "last_name")
private String lastName;
}
Creating and Populating the User Table
In the User Service, I need a couple of methods. I need a method to find users by their ID, a method to check whether the user exists in Cognito, and a method to create a user entry in my PSQL database.
The first method will just use the UserDAO
to search the database for a user record. If it has one, we will return that user entry. If we do not have an entry, we will call the Cognito check method.
The check method will use our Cognito service to query for the user data with the access token provided in springs security context. If the user does not exist, then there is no reason for this current access token to be able to access our server. I will throw a log message in this case.
If the user does exist in Cognito, we will pull that user data and call the method to create a new User entry in our db. This method will create a new User entity with whatever necessary data we want to extract from the Cognito User object that the user input for your required fields during sign up. I will also make the primary key equal to the user ID.
@Slf4j
@Service("userService")
@AllArgsConstructor
public class UserServiceImpl implements UserService {
@Autowired
private UserDAO userDao;
@Autowired
private CognitoService cognitoService;
@Override
public User getCurrentUser() {
return getUserByID(getCurrentId());
}
@Override
public User getUserByID(String userId) {
User user = userDao.findById(userId).orElse(null);
// If the user isn't found via the DAO, look for the user in Cognito
if (user == null) {
log.info("User not found with ID: " + userId + " -- Checking Cognito");
user = checkForUserInCognito(userId);
}
return user;
}
private User checkForUserInCognito(String userId) {
Map<String, String> attributes = cognitoService.getUserAttributes(userId);
if (attributes == null) {
log.error("User not found in Cognito: " + userId);
/**
If the user isn't found in Cognito, but the userID was retrieved from
some JWT, then the user wasn't authenticated properly. This is a
security issue that will need to be audited.
*/
if (Objects.equals(userId, getCurrentId())) {
log.error("User not authenticated properly: " + userId);
}
return null;
}
// If the user does exist in Cognito but not our database, we'll create a new
// entry in our database that represents the Cognito user.
return createUserRecord(userId, attributes);
}
private User createUserRecord(String userId, Map<String, String> attributes) {
User newUser = new User();
newUser.setId(userId);
newUser.setEmail(attributes.get("email"));
newUser.setFirstName(attributes.get("given_name"));
newUser.setLastName(attributes.get("family_name"));
newUser.setEmailVerified(Boolean.parseBoolean(attributes.get("email_verified")));
// Here it's possible to add phone number, DOB, etc. Will just need to configure Cognito for it.
// Now that the user is saved in the database, we can return that up the method tree to our original get user by ID method.
return userDao.save(newUser);
}
@Override
public User updateUser(User user) {
User existingUser = userDao.findById(user.getId()).orElse(null);
Map<String, String> newAttributes = new HashMap<>();
if (existingUser == null) {
log.error("User not found with ID: " + user.getId());
return null;
}
// Here we compare the values also stored in Cognito and update them if necessary.
if (!existingUser.getEmail().equals(user.getEmail()))
newAttributes.put("email", user.getEmail());
if (!existingUser.getFirstName().equals(user.getFirstName()))
newAttributes.put("given_name", user.getFirstName());
if (!existingUser.getLastName().equals(user.getLastName()))
newAttributes.put("family_name", user.getLastName());
if (existingUser.isEmailVerified() != user.isEmailVerified())
newAttributes.put("email_verified", String.valueOf(user.isEmailVerified()));
if (!newAttributes.isEmpty())
cognitoService.updateUserAttributes(user.getId(), newAttributes);
// If there are other required fields you want the user to be able to update
// you can add them here using the pattern shown above.
return userDao.save(user);
}
@Override
public String getCurrentId() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.getName();
}
}
Now I can relate different table rows to my users and perform whatever SQL queries necessary for data analysis!
For things to add later, I plan to add Caffiene for caching the user data since that will be frequently used.
Hopefully this article helped you in some way. Have a good one!