API security: How to implement Authentication and Authorization with AWS Cognito in Spring Boot
David
Posted on November 23, 2023
When I implemented the authentication and authorization process with Spring Security 6, I didn't find any helpful and updated articles on this matter. So, I'll save you some time and show you how you can do that. In this blog post, we'll focus on implementing the registration, login processes, and the JWT Token authorization with AWS Cognito. You're welcome, let's get started!
What is AWS Cognito
Amazon Cognito is a product from Amazon Web Services (AWS) that controls user authentication and access for mobile apps. It's an identity platform for web and mobile apps.
Cognito's main features include:
- User directory
- Authentication server
- Authorization service
- User sign-up and authentication
- Temporary security credentials
- Session management
- Forgotten password functionality
Cognito also provides a secure identity store that can scale to millions of users. This store securely stores user profile data for users who sign up directly and for federated users who sign in with external identity providers.
You can learn more on their FAQ page.
The project
Let's build an app like pramp.com.
- Users can obviously register and log in.
- Admin can create many questions
- Default users can create many interview sessions
- Each interview session has 2 questions from the same type
Here is the ERD for more understanding
I will assume that you already know how to install Spring Boot. Just make sure that you have the following dependencies in your pom.xml
file
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-cognitoidp</artifactId>
<version>1.11.934</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.26</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Let's break down each dependency:
-
spring-boot-starter-security: is part of the Spring Boot framework and provides a comprehensive set of security-related features.
- Authentication: Helps in user authentication.
- Authorization: Supports role-based and permission-based access control.
- Common security configurations out of the box.
- Integration with various authentication providers.
-
aws-java-sdk-cognitoidp: is part of the AWS SDK for Java and specifically focuses on Amazon Cognito Identity Pools.
- Provides Java APIs for interacting with Amazon Cognito Identity Pools.
- Enables your Java application to integrate with Amazon Cognito for user management and authentication.
- Includes functionality for user sign-up, sign-in, and other identity-related operations.
mysql-connector-java: is the official JDBC driver for MySQL databases, allowing Java applications to connect and interact with MySQL databases. Because you will need to create a local database. Look up online on how to do it with IntelliJ.
-
spring-boot-starter-oauth2-resource-server: is part of Spring Boot and is designed to set up an OAuth 2.0 Resource Server.
- Configures the application to act as a resource server, capable of processing and validating OAuth 2.0 access tokens.
- Allows the application to secure its resources and endpoints using OAuth 2.0 authentication and authorization.
The architecture:
We'll use the Domain Driven Design principles:
- controller: For handling HTTP requests and defining endpoints.
- service: For implementing business logic.
- repository: For data access to interact with databases like DynamoDB.
- model: For defining domain entities and value objects.
- requests: We could use Data Transfer Objects (DTO) that encapsulate data passed between layers. But for this project, we will use request classes, which serve as a form of DTO. Those classes define the structure of the data you expect to receive in your HTTP request.
- config: For Spring configuration classes.
- security: For security-related classes.
Here is what your src/main
folder should look like:
├── java
│ └── com
│ └── example
│ └── chat_app
│ ├── ChatAppApplication.java
│ ├── config
│ │ ├── CognitoConfig.java
│ │ └── SecurityConfig.java
│ ├── controller
│ │ ├── QuestionController.java
│ │ └── UserController.java
│ ├── dto
│ ├── model
│ │ ├── Question.java
│ │ └── User.java
│ ├── repository
│ │ ├── QuestionRepository.java
│ │ └── UserRepository.java
│ ├── requests
│ │ ├── CreateQuestionRequest.java
│ │ ├── UserLoginRequest.java
│ │ └── UserRegistrationRequest.java
│ ├── security
│ └── service
│ ├── CognitoService.java
│ ├── QuestionService.java
│ ├── QuestionServiceImpl.java
│ ├── UserService.java
│ └── UserServiceImpl.java
└── resources
├── application.properties
├── simple.priv
├── static
└── templates
Registration and Login
- Create a Cognito user pool: check this tutorial or this video to do it.
- Add the following to your application.properties file
spring.security.oauth2.client.registration.cognito.client-id=your_cognito_app_client_id
spring.security.oauth2.client.registration.cognito.client-secret=your_cognito_app_client_secret
spring.security.oauth2.client.registration.cognito.scope=openid
spring.security.oauth2.client.provider.cognito.issuer-uri=https://cognito-idp.us-east-2.amazonaws.com/us-east-2_EDjR6VYYS https://cognito-idp.<REGION>.amazonaws.com/<POOL Id>
aws.accessKeyId=aws_access_key
aws.secretKey=aws_secret_key
aws.region=aws_region
spring.datasource.url=database_uri
spring.datasource.username=database_username
spring.datasource.password=database_password
- Add the controller actions for your login and register routes in the
UserController
:
@Autowired
private UserServiceImpl userService;
@PostMapping(value = "/register", consumes = {"application/json"})
public ResponseEntity<User> registerUser(@Valid @RequestBody UserRegistrationRequest userRegistrationRequest) {
System.out.println(userRegistrationRequest);
// Perform user registration
User registeredUser = userService.registerUser(userRegistrationRequest);
if (registeredUser != null) {
return ResponseEntity.ok(registeredUser);
} else {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@PostMapping("/login")
public ResponseEntity<User> loginUser(@Valid @RequestBody UserLoginRequest userLoginRequest) {
// Perform user login
User loggedInUser = userService.loginUser(userLoginRequest.getUsername(), userLoginRequest.getPassword());
if (loggedInUser != null) {
// Successful login
return ResponseEntity.ok(loggedInUser);
} else {
// Invalid login, return an appropriate response
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
}
- Add the
registerUser
andloginUser
methods in theUserService
interface and implement them in theUserServiceImpl
class:
public interface UserService {
User registerUser(UserRegistrationRequest userRegistrationRequest);
User loginUser(String username, String password);
}
@Service
public class UserServiceImpl implements UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private CognitoService cognitoService;
@Override
public User registerUser(UserRegistrationRequest userRegistrationRequest) {
String username = userRegistrationRequest.getUsername();
String password = userRegistrationRequest.getPassword();
String email = userRegistrationRequest.getEmail();
// Register the user with Amazon Cognito
return cognitoService.registerUser(username, email, password);
}
@Override
public User loginUser(String username, String password) {
return cognitoService.loginUser(username, password);
}
}
- Implement the registration and login logic in the CognitoService:
@Value("${spring.security.oauth2.client.registration.cognito.client-id}")
private String clientId;
@Value("${spring.security.oauth2.client.registration.cognito.client-secret}")
private String clientSecret;
@Value("${spring.security.oauth2.client.registration.cognito.scope}")
private String scope;
@Value("${spring.security.oauth2.client.provider.cognito.issuer-uri}")
private String issuerUri;
@Autowired
private AWSCognitoIdentityProvider cognitoIdentityProvider;
@Autowired
private UserRepository userRepository;
public User registerUser(String username, String email, String password) {
// Set up the AWS Cognito registration request
SignUpRequest signUpRequest = new SignUpRequest()
.withClientId(clientId)
.withUsername(username)
.withPassword(password)
.withUserAttributes(
new AttributeType().withName("email").withValue(email)
);
// Register the user with Amazon Cognito
try {
SignUpResult signUpResponse = cognitoIdentityProvider.signUp(signUpRequest);
User registeredUser = new User();
registeredUser.setUsername(username);
registeredUser.setEmail(email);
registeredUser.setPassword(password);
return userRepository.save(registeredUser);
} catch (Exception e) {
throw new RuntimeException("User registration failed: " + e.getMessage(), e);
}
}
public User loginUser(String username, String password) {
// Set up the authentication request
InitiateAuthRequest authRequest = new InitiateAuthRequest()
.withAuthFlow("USER_PASSWORD_AUTH")
.withClientId(clientId)
.withAuthParameters(
Map.of(
"USERNAME", username, // Use email as the username
"PASSWORD", password
)
);
try {
InitiateAuthResult authResult = cognitoIdentityProvider.initiateAuth(authRequest);
System.out.println(authResult);
AuthenticationResultType authResponse = authResult.getAuthenticationResult();
// At this point, the user is successfully authenticated, and you can access JWT tokens:
String accessToken = authResponse.getAccessToken();
String idToken = authResponse.getIdToken();
String refreshToken = authResponse.getRefreshToken();
// You can decode and verify the JWT tokens for user information
User loggedInUser = new User();
loggedInUser.setUsername(username);
loggedInUser.setAccessToken(accessToken); // Store the token for future requests
return loggedInUser;
} catch (Exception e) {
throw new RuntimeException("User login failed: " + e.getMessage(), e);
}
}
- Create the
AWSCognitoIdentityProvider
bean in yourCognitoConfig
class:
@Configuration
public class CognitoConfig {
@Value("${aws.accessKeyId}")
private String accessKey;
@Value("${aws.secretKey}")
private String secretKey;
@Value("${aws.region}")
private String region;
@Bean
public AWSCognitoIdentityProvider cognitoIdentityProvider() {
BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
return AWSCognitoIdentityProviderClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
.withRegion(Regions.fromName(region))
.build();
}
}
- Implement the UserLoginRequest and UserRegistrationRequest classes:
public class UserLoginRequest {
@NotBlank(message = "Username is required")
private String username;
@NotBlank(message = "Password is required")
private String password;
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
}
public class UserRegistrationRequest {
@NotBlank(message = "Username is required")
private String username;
@NotBlank(message = "Password is required")
private String password;
@Email(message = "Invalid email address")
private String email;
// Getters and setters for the fields
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public String getEmail() {
return email;
}
}
- Implement the User model:
@Entity
@Table(
name = "users",
uniqueConstraints = {
@UniqueConstraint(columnNames = "email"),
@UniqueConstraint(columnNames = "username")
}
)
public class User {
@jakarta.persistence.Id
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column
@NotEmpty(message = "Username is required")
@Size(min = 5, max = 50)
private String username;
@Column
@NotEmpty(message = "Email is required")
private String email;
@Column
@NotEmpty(message = "Password is required")
private String password;
@Transient
private String accessToken;
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getAccessToken() {
return accessToken;
}
public String getUsername() {
return username;
}
public String getEmail() {
return email;
}
public String getPassword() {
return password;
}
public void setUsername(String username) {
this.username = username;
}
public void setEmail(String email) {
this.email = email;
}
public void setPassword(String password) {
this.password = password;
}
public void setId(int id) {
this.id = id;
}
public int getId() {
return id;
}
- Finally, add this to your SecurityConfig class:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(
requests -> requests
.requestMatchers("/admin")
.hasAnyRole("Admin", "Editor")
.requestMatchers("/login")
.permitAll()
.requestMatchers("/register")
.permitAll()
.requestMatchers("/logout")
.permitAll()
.anyRequest()
.authenticated()
);
http.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
Now start your Spring app, open Postman, and ensure your app is connected to your database. Run the POST /register
request and you should get the following result:
{
"id": <user_id>,
"username": <username>,
"email": <email>,
"password": <password>,
"accessToken": null
}
You should also be able to see the new user in your AWS Cognito app.
Run the POST /login
request and you should get the following result:
{
"id": <user_id>,
"username": <username>,
"email": <email>,
"password": null,
"accessToken": <access_token>,
}
Don't forget to confirm manually the user account from the AWS Cognito. You can look up the documentation to see how you can also do it using Spring Boot.
Verifying authorization
Spring Security supports protecting endpoints by using two forms of OAuth 2.0 Bearer Tokens:
- JWT
Opaque Tokens
We are going to use JWT. You can learn more about it in the Spring documentation.
Spring Boot will do it by checking the token provided by our client (in this case, Postman), verifying if it's a valid Cognito token, and either returning success or unauthorized statuses.
For moreUpdate your
Securityconfig
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JWSAlgorithm jwsAlgorithm = JWSAlgorithm.RS256;
private final JWEAlgorithm jweAlgorithm = JWEAlgorithm.RSA_OAEP_256;
private final EncryptionMethod encryptionMethod = EncryptionMethod.A256GCM;
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
URL jwkSetUri;
@Value("${sample.jwe-key-value}")
RSAPrivateKey key;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(
requests -> requests
.requestMatchers("/admin")
.hasAnyRole("Admin", "Editor")
.requestMatchers("/login")
.permitAll()
.requestMatchers("/register")
.permitAll()
.requestMatchers("/logout")
.permitAll()
.anyRequest()
.authenticated()
)
.oauth2ResourceServer((oauth2) -> oauth2.jwt(withDefaults()));
http.csrf(AbstractHttpConfigurer::disable);
return http.build();
}
@Bean
JwtDecoder jwtDecoder() {
return new NimbusJwtDecoder(jwtProcessor());
}
private JWTProcessor<SecurityContext> jwtProcessor() {
JWKSource<SecurityContext> jwsJwkSource = new RemoteJWKSet<>(this.jwkSetUri);
JWSKeySelector<SecurityContext> jwsKeySelector = new JWSVerificationKeySelector<>(this.jwsAlgorithm,
jwsJwkSource);
JWKSource<SecurityContext> jweJwkSource = new ImmutableJWKSet<>(new JWKSet(rsaKey()));
JWEKeySelector<SecurityContext> jweKeySelector = new JWEDecryptionKeySelector<>(this.jweAlgorithm,
this.encryptionMethod, jweJwkSource);
ConfigurableJWTProcessor<SecurityContext> jwtProcessor = new DefaultJWTProcessor<>();
jwtProcessor.setJWSKeySelector(jwsKeySelector);
jwtProcessor.setJWEKeySelector(jweKeySelector);
return jwtProcessor;
}
private RSAKey rsaKey() {
RSAPrivateCrtKey crtKey = (RSAPrivateCrtKey) this.key;
Base64URL n = Base64URL.encode(crtKey.getModulus());
Base64URL e = Base64URL.encode(crtKey.getPublicExponent());
return new RSAKey.Builder(n, e).privateKey(this.key).keyUse(KeyUse.ENCRYPTION).build();
}
}
- Add this line to your
application.properties
file:
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://cognito-idp.<region>.amazonaws.com/<userPoolID>/.well-known/jwks.json
sample.jwe-key-value: classpath:simple.priv
- Create a
simple.priv
file and paste the following key. Or create your own RSA key:
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDcWWomvlNGyQhA
iB0TcN3sP2VuhZ1xNRPxr58lHswC9Cbtdc2hiSbe/sxAvU1i0O8vaXwICdzRZ1JM
g1TohG9zkqqjZDhyw1f1Ic6YR/OhE6NCpqERy97WMFeW6gJd1i5inHj/W19GAbqK
LhSHGHqIjyo0wlBf58t+qFt9h/EFBVE/LAGQBsg/jHUQCxsLoVI2aSELGIw2oSDF
oiljwLaQl0n9khX5ZbiegN3OkqodzCYHwWyu6aVVj8M1W9RIMiKmKr09s/gf31Nc
3WjvjqhFo1rTuurWGgKAxJLL7zlJqAKjGWbIT4P6h/1Kwxjw6X23St3OmhsG6HIn
+jl1++MrAgMBAAECggEBAMf820wop3pyUOwI3aLcaH7YFx5VZMzvqJdNlvpg1jbE
E2Sn66b1zPLNfOIxLcBG8x8r9Ody1Bi2Vsqc0/5o3KKfdgHvnxAB3Z3dPh2WCDek
lCOVClEVoLzziTuuTdGO5/CWJXdWHcVzIjPxmK34eJXioiLaTYqN3XKqKMdpD0ZG
mtNTGvGf+9fQ4i94t0WqIxpMpGt7NM4RHy3+Onggev0zLiDANC23mWrTsUgect/7
62TYg8g1bKwLAb9wCBT+BiOuCc2wrArRLOJgUkj/F4/gtrR9ima34SvWUyoUaKA0
bi4YBX9l8oJwFGHbU9uFGEMnH0T/V0KtIB7qetReywkCgYEA9cFyfBIQrYISV/OA
+Z0bo3vh2aL0QgKrSXZ924cLt7itQAHNZ2ya+e3JRlTczi5mnWfjPWZ6eJB/8MlH
Gpn12o/POEkU+XjZZSPe1RWGt5g0S3lWqyx9toCS9ACXcN9tGbaqcFSVI73zVTRA
8J9grR0fbGn7jaTlTX2tnlOTQ60CgYEA5YjYpEq4L8UUMFkuj+BsS3u0oEBnzuHd
I9LEHmN+CMPosvabQu5wkJXLuqo2TxRnAznsA8R3pCLkdPGoWMCiWRAsCn979TdY
QbqO2qvBAD2Q19GtY7lIu6C35/enQWzJUMQE3WW0OvjLzZ0l/9mA2FBRR+3F9A1d
rBdnmv0c3TcCgYEAi2i+ggVZcqPbtgrLOk5WVGo9F1GqUBvlgNn30WWNTx4zIaEk
HSxtyaOLTxtq2odV7Kr3LGiKxwPpn/T+Ief+oIp92YcTn+VfJVGw4Z3BezqbR8lA
Uf/+HF5ZfpMrVXtZD4Igs3I33Duv4sCuqhEvLWTc44pHifVloozNxYfRfU0CgYBN
HXa7a6cJ1Yp829l62QlJKtx6Ymj95oAnQu5Ez2ROiZMqXRO4nucOjGUP55Orac1a
FiGm+mC/skFS0MWgW8evaHGDbWU180wheQ35hW6oKAb7myRHtr4q20ouEtQMdQIF
snV39G1iyqeeAsf7dxWElydXpRi2b68i3BIgzhzebQKBgQCdUQuTsqV9y/JFpu6H
c5TVvhG/ubfBspI5DhQqIGijnVBzFT//UfIYMSKJo75qqBEyP2EJSmCsunWsAFsM
TszuiGTkrKcZy9G0wJqPztZZl2F2+bJgnA6nBEV7g5PA4Af+QSmaIhRwqGDAuROR
47jndeyIaMTNETEmOnms+as17g==
-----END PRIVATE KEY-----
That's it. Now let's say you want to create a question. Simply create the endpoint and add the controller action in the QuestionController:
@Autowired
private QuestionServiceImpl questionService;
@PostMapping(value = "/questions", consumes = {"application/json"})
public ResponseEntity<Question> createQuestion(@AuthenticationPrincipal Jwt jwt, @RequestBody CreateQuestionRequest request) {
Question createdQuestion = questionService.createQuestion(jwt, request);
return ResponseEntity.status(HttpStatus.CREATED).body(createdQuestion);
}
@AuthenticationPrincipal
is used to inject the JSON Web Token (JWT) directly into the method parameter and check if it's valid one.
I'll let you implement the logic in the service, request, repository and model. Ciao.
Posted on November 23, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 23, 2023