API security: How to implement Authentication and Authorization with AWS Cognito in Spring Boot

November 22, 2023 (1y ago)

Read on Dev.to →

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:

Image description

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.

Here is the ERD for more understanding

Image description

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:

  1. 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.
  2. 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.
  3. 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.

  4. 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:

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

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
@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();
        }
    }
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);
    }
}
@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);
        }
    }
@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();
    }
}
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;
    }
 
}
 
@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;
    }
@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:

@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();
    }
}
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
-----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.

Comments (2)

Add Comment
vdelitz
vdelitz@vdelitzNovember 23, 2023 (1y ago)

Nice article, thanks!

CedricLapi
CedricLapi@cedriclapiNovember 27, 2023 (1y ago)

Interesting!