Introduction and the Authorisation Server

By Mohamed Elmedany

9 min read

An introduction to the chat system, including the design and implementation of its authorisation server.

Introduction and the Authorisation Server
Authors

In this deep dive, we will explore my approach to implementing a chat system designed to be secure, distributed, and scalable. We will break down the technical challenges, decisions, and technologies involved in building this system

Whether you are interested in the fundamentals of distributed architecture or simply want to understand what goes into creating a high-performance chat application, this article series will guide you through the process in a friendly and engaging way.

So, let's dive in!

Introduction

First, let's have a look at the desired end architecture.

distributed-chat-system
  • Authorisation Server: Handles user registration, authentication and authorisation centrally, ensuring secure access to the system.
  • Chat API: Provides the core chat functionalities, enabling users to send / receive messages, create and manage conversations.
  • Chat Web Client: Offers a user-friendly interface through which users can interact with the chat system via web browsers.
  • Messaging Channel: Enables the reliable and asynchronous exchange of messages between system components.
  • Websockets Server: Provides real-time update functionalities to users by keeping track of online / offline users and push update when online.

Each of these components is a standalone service that can run independently. When we connect all the components, like pieces of a puzzle, we achieve the desired functionality.

In this first part, we'll focus on the initial component: The Authorisation Server.

Authorisation Server

The Authorisation Server is the first component in our system, responsible for securing access to system resources using the OAuth 2.0 protocol. It handles the registration and authentication of user accounts and client applications, issues access and refresh tokens, and enforces access scopes. This ensures that only authorised parties can access and benefit from system resources. Let's break this down into specific functional requirements to better understand the functionality and ensure effective implementation and testing.

Functional Requirements

  • Token-based authentication using opaque tokens
  • Client application registration, authentication and authorisation
  • User account registration, authentication and authorisation

Technologies

  • Java 21
  • Spring boot and Spring Data (3.3.2 the current latest release)
  • Spring Security with OAuth 2.0 Authorization Server (6.3.1 the current latest release)
  • Flyway
  • Postgres
  • Gradle

Implementation

First, we will create an empty directory to serve as the root of our monorepo for all system components. Next, we will set up a separate directory specifically for the authorisation server.

Inside the authorisation server directory, we will create a build.gradle file and add the following dependencies:

dependencies {
  implementation("org.springframework.boot:spring-boot-starter-web")
  implementation("org.springframework.boot:spring-boot-starter-validation")
  implementation("org.passay:passay")

  implementation("org.springframework.boot:spring-boot-starter-security")
  implementation("org.springframework.boot:spring-boot-starter-oauth2-authorization-server")

  implementation("org.springframework.boot:spring-boot-starter-data-jpa")

  annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")

  runtimeOnly("org.postgresql:postgresql")
  runtimeOnly("org.flywaydb:flyway-database-postgresql")
}

Next, we will add AuthorizationServerConfiguration.java and SecurityConfiguration.java to configure the security of our server:

@Configuration
public class AuthorizationServerConfiguration {
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public SecurityFilterChain authorizationSecurityFilterChain(HttpSecurity http, ApiAuthenticationProvider apiAuthenticationProvider) {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .tokenEndpoint(tokenEndpoint -> tokenEndpoint
                        .accessTokenRequestConverter(apiAuthenticationConverter())
                        .authenticationProvider(apiAuthenticationProvider));

        return http.build();
    }

    @Bean
    public ApiAuthenticationProvider apiAuthenticationProvider(UserDetailsService userDetailsService,
                                                               OAuth2TokenGenerator<?> tokenCustomizer,
                                                               OAuth2AuthorizationService authorizationService,
                                                               PasswordEncoder passwordEncoder) {
        return new ApiAuthenticationProvider(authorizationService, tokenCustomizer, userDetailsService, passwordEncoder);
    }

    @Bean
    public OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
                                                           RegisteredClientRepository registeredClientRepository) {
        return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
    }

    @Bean
    public ApiAuthenticationConverter apiAuthenticationConverter() {
        return new ApiAuthenticationConverter();
    }

    @Bean
    public RegisteredClientRepository jdbcRegisteredClientRepository(JdbcTemplate jdbcTemplate) {
        return new JdbcRegisteredClientRepository(jdbcTemplate);
    }
}

Here, we configure the authorisation server's main securityFilterChain bean with the following:

  • apiAuthenticationProvider bean, which is responsible for handling authentication for the defined grant type.
  • authorizationService bean, which is used by the authorisation provider as the backing service for authorisation CRUD operations.
  • apiAuthenticationConverter bean, which validates the required request parameters for our defined grant type.
  • jdbcRegisteredClientRepository bean, which is used by authorization service for registered clients CRUD operations.

RegisteredClient represents an authorised client application registered with the authorisation server, such as the Chat API. It includes information about the client application, such as its credentials, authorisation grants, and redirect URIs.

Both JdbcOAuth2AuthorizationService and JdbcRegisteredClientRepository require an accessible datasource with a predefined schema to perform CRUD operations on authorisations and registered clients. We will define this setup in the application properties.

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {
    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http,
                                                          ApiAuthenticationExceptionFilter apiAuthenticationExceptionFilter,
                                                          OpaqueTokenIntrospector tokenIntrospector) {
        return http.cors(withDefaults())
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorizeRequests -> authorizeRequests.requestMatchers("/v1/**").hasAuthority("write")
                        .anyRequest().authenticated())
                .formLogin(AbstractHttpConfigurer::disable)
                .logout(LogoutConfigurer::permitAll)
                .addFilterAfter(apiAuthenticationExceptionFilter, ExceptionTranslationFilter.class)
                .oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer
                        .opaqueToken(opaqueTokenConfigurer -> opaqueTokenConfigurer.introspector(tokenIntrospector)))
                .build();
    }

    @Bean
    public OpaqueTokenIntrospector tokenIntrospector(OAuth2AuthorizationService authorizationService) {
        return new TokenIntrospector(authorizationService);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

Here, we configure the default securityFilterChain bean with the following:

  • Require all requests to /v1/**, the exposed API in our authorisation server, to have write scope. This scope must be assigned to the client application making the requests.
  • apiAuthenticationExceptionFilter bean, which handles authentication exceptions.
  • tokenIntrospector bean, which make it possible to internally introspect and authenticate API calls to our authorisation server. In other words, the authorisation server also functions as a resource server.

Next, we add TokenConfiguration.java configuration for access and refresh token generation and customization:

@Configuration
public class TokenConfiguration {
    @Bean
    public OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator(OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer) {
        OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
        accessTokenGenerator.setAccessTokenCustomizer(accessTokenCustomizer);
        OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();

        return new DelegatingOAuth2TokenGenerator(accessTokenGenerator, refreshTokenGenerator);
    }

    @Bean
    public OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer() {
        return context -> {
            UserDetails userDetails = extractUserDetails(context.getPrincipal());
            validateUsername(userDetails);

            context.getClaims()
                    .claim(CLAIMS_AUTHORITIES_KEY, getAuthorities(userDetails))
                    .claim(CLAIMS_USERNAME_KEY, userDetails.getUsername());
        };
    }
}

We need to add UserDetailsServiceImpl.java, which implements UserDetailsService to retrieve user accounts by username from the database:

@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserRepository userRepository;

    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) {
        Optional<User> dbUser = userRepository.findByUsernameIgnoreCaseAndActive(username, true);

        return dbUser.map(u -> User.builder()
                        .username(u.getUsername())
                        .password(new String(u.getPassword()))
                        .authorities(getAuthorities(u.getRoles()))
                        .build())
                .orElseThrow(() -> new UsernameNotFoundException("No account found for given username."));
    }
}

Finally, we add application.properties file to define our server properties:

server.port=9000

spring.datasource.url=jdbc:postgresql://localhost:5432/<database_name>?stringtype=unspecified&serverTimezone=UTC
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.jdbc-url=${spring.datasource.url}
spring.datasource.hikari.username=postgres

spring.flyway.enabled=true
spring.flyway.user=postgres
spring.flyway.encoding=UTF-8
spring.flyway.locations=classpath:db/migrations
spring.flyway.schemas=public
spring.flyway.table=migrations_log
spring.flyway.baseline-on-migrate=true
spring.flyway.url=${spring.datasource.url}

Here, we define the server port, configure the Spring datasource, and set up Flyway for database migrations. If you're not familiar with database migrations, you can read more about them in Flyway documentation.

In the db/migrations directory, we have the essential SQL migration scripts to initialise the required database tables. These scripts define the schema for RegisteredClient and OAuth2Authorization.

This is the configuration required so far, covering the first functional requirement. Next, we will address the remaining requirements, taking a methodical approach and ensuring each component is fully functional before moving on to the next.

Account registration

We can’t chat without having an account first, can we? So, we need to register new accounts. For this, we will create an endpoint to receive requests, a service to process them, and a database to store the accounts. It might seem like a lot, but it’s just the standard setup for account CRUD operations.

Let’s start from the bottom up. First, we define our users database table:

CREATE TABLE IF NOT EXISTS users (
    id             UUID             PRIMARY KEY,
    username       VARCHAR(50)      UNIQUE NOT NULL,
    password       BYTEA            NOT NULL,
    firstname      VARCHAR(100),
    lastname       VARCHAR(100),
    active         BOOLEAN          NOT NULL ,
    created_at     TIMESTAMP        NOT NULL,
    updated_at     TIMESTAMP        NOT NULL
)

Next, we add User.java as the user entity class:

@Entity
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    @Column(unique = true)
    private String username;
    @Column(name = "password", columnDefinition = "bytea")
    private byte[] password;
    private String firstname;
    private String lastname;
    private boolean active;
    @CreationTimestamp
    private OffsetDateTime createdAt;
    @UpdateTimestamp
    private OffsetDateTime updatedAt;
}

Note that passwords are defined as a byte array instead of a String object. This improves security because String objects are immutable and remain in memory until garbage collection, making them vulnerable. Byte arrays can be cleared from memory immediately after use, reducing the risk of exposure. Additionally, once the password is validated, it will be encrypted, and no further string operations will be performed on it.

Next, we will add UserRepository.java interface, extending Spring's JpaRepository:

@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
    Optional<User> findByUsernameIgnoreCaseAndActive(String username, boolean active);
}

And RegistrationService.java:

@Service
public class RegistrationService {
    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;
    private final UserMapper userMapper;

    public RegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository, UserMapper userMapper) {
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
        this.userMapper = userMapper;
    }

    public void createUser(SignupRequest signupRequest) {
        if (usernameExists(signupRequest.getUsername())) {
            throw new UsernameAlreadyExistsException("Username already exists.");
        }

        User user = userMapper.toUser(signupRequest);
        user.setPassword(passwordEncoder.encode(signupRequest.getPassword()).getBytes(StandardCharsets.UTF_8));
        user.setActive(true);

        userRepository.save(user);
    }

    private boolean usernameExists(String username) {
        return userRepository.findByUsername(username).isPresent();
    }
}

Then RegistrationController.java:

@RestController
@RequestMapping("/v1")
public class RegistrationController {
    private static final Logger LOGGER = LoggerFactory.getLogger(RegistrationController.class);

    private final RegistrationService registrationService;

    public RegistrationController(RegistrationService registrationService) {
        this.registrationService = registrationService;
    }

    @PostMapping(value = "signup", produces = {MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_PLAIN_VALUE})
    public ResponseEntity<APIResponse<Void>> signup(@Valid @RequestBody SignupRequest registrationRequest) {
        LOGGER.debug("Creating new user account");
        registrationService.createUser(signupRequest);
        return new ResponseEntity<>(APIResponse.emptyResponse(), HttpStatus.CREATED);
    }
}

Finally, we add SignupRequest.java, which contains the account details to be created:

public class SignupRequest {
    @NotBlank(message = "firstname cannot be null or empty.")
    @Size(min = 3, max = 100, message = "firstname accepts only characters and it should be between 3 and 100 characters.")
    private String firstname;
    @NotBlank(message = "lastname cannot be null or empty.")
    @Size(min = 3, max = 100, message = "lastname accepts only characters and it should be between 3 and 100 characters.")
    private String lastname;
    @NotBlank(message = "username cannot be null or empty.")
    @Size(min = 3, max = 50, message = "username accepts only characters and it should be between 3 and 50 characters.")
    private String username;
    @NotBlank(message = "password cannot be null or empty.")
    @ValidPassword
    private String password;
}

Here, we use Spring validation to apply rules for valid account details. The @NotBlank and @Size annotations are provided by Spring, and I introduced @ValidPassword to enforce custom password rules using Passay library. You can find the defined rules in PasswordConstraintsValidator.java.

Now that we have added the necessary classes, we are ready to start testing the registration functionality. Let’s start up the authorisation server and give it a go!

SignUp

As we explained earlier, only registered clients can access our authorisation server via the grant flow. Otherwise, all requests will be rejected with an access denied exception. A signup request creates an account on the authorisation server, which will later be used to access the resource server. Therefore, it must be authorised and authenticated. To facilitate this, V001__init_registered_clients migration script inserts a registered client into the database, available for us to use. This fulfils the second functional requirement, which we will verify in the later steps.

Test Case 1: Unauthorised client call

We will first try to create a new account with a valid request body and headers but leave out the Authorization header. What do you think we will get in response?

Correct, we can expect a 403 Forbidden exception since we did not provide any authentication.

signup-unauthenticated

Test Case 2: Obtain access token with client credentials grant flow

To avoid the exception we encountered in the first case, we need first to obtain an access token for the client application using the client_credentials grant flow, as we need to authenticate the client to call the API without involving any user account. We can do this by calling the /oauth2/token endpoint with Authorization header containing the client ID and secret, and setting grant_type to client_credentials.

client-credentials-success.png

Test Case 3: Successful signup

We can now retry the same call as in the first test case, but with the correct Authorization header. This time, we will be able to successfully create a new user account.

signup-success

Login

Now that we have covered account registration, we will use the newly created account to obtain an access token (i.e., login), which can later be used to access system resources. Unlike the client_credentials grant flow, this process involves the client application using the account credentials along with its own client ID and secret to first authenticate itself to the authorisation server and then obtain an access token for the account. This grant type is known as the Resource Owner Password Credentials or ROPC grant.

Test Case 1: Successful login

login-success

The tokens can now be stored securely, preferably on the backend, and used to access system resources on behalf of the user. Note that the expiry date is also part of the response, so the client application knows in advance when the access token will no longer be valid

Caution: According to the latest OAuth 2.0 Security Best Practices, using ROPC grant flow is discouraged due to the insecure exposure of passwords to the client application. However, we're using it here for simplicity. Be careful when using it in public-facing applications. OAuth 2.0 Security Best Current Practice.

Test Case 2: Wrong credentials

To further test our implementation, we will make the same call again but with an invalid password. What do you think will happen?

Correct again! We will get a 401 Unauthorized exception since the credentials, in this case, the password, are not valid.

login-failure

Refresh Token

The access token does not last forever, does it? As we saw in the successful login case, it will eventually expire. That is why we issue a refresh token along with the access token. The refresh token can be used to obtain a new access token without requiring the user to log in again. This ensures continuous access to the system resources without frequent logins and reduces the load on the authorisation server from repeated authentication.

To refresh the access token, we need to call the /oauth2/token endpoint with Authorization header containing the client ID and secret. Set grant_type to refresh_token and provide the refresh token that we securely saved earlier.

refresh-token-success

Here, we have invalidated both the old access and refresh tokens; only the new ones are valid. I trust you now know where to securely store the new values.

Logout

Finally, we can manually revoke the access token (i.e., logout) when needed. This will invalidate the access token, ensuring it can no longer be used even before its expiry date. To do this, we need to call the /oauth2/revoke endpoint with the Authorization header containing the client ID and secret, and the token parameter with the value of the token to be invalidated.

revoke-token-success

With that complete, we have covered the basics of the functional requirements and tested our implementation to ensure it works as expected.

Source Code & Examples

Some code snippets are intentionally omitted to keep this article concise. Additionally, there are further enhancements and features not covered here. The complete source code and the latest implementation, can be found at this GitHub repository.

Conclusion

We have covered the first component of the system: configuring and using an authorisation server to manage authentication, authorisation, and registration with the help of the Spring Framework. This setup can be extended to incorporate more complex or custom functionalities based on specific use cases or business requirements.

In the next part, we will dive into post-account registration actions, the messaging channel, and the Chat API. Stay tuned!

References


Upcoming events

  • Mastering Event-Driven Design

    PLEASE RSVP SO THAT WE KNOW HOW MUCH FOOD WE WILL NEED Are you and your team struggling with event-driven microservices? Join us for a meetup with Mehmet Akif Tütüncü, a senior software engineer, who has given multiple great talks so far and Allard Buijze founder of CTO and founder of AxonIQ, who built the fundaments of the Axon Framework. RSVP for an evening of learning, delicious food, and the fusion of creativity and tech! 🚀 18:00 – 🚪 Doors open to the public 18:15 – 🍕 Let’s eat 19:00 – 📢 Getting Your Axe On Event Sourcing with Axon Framework 20:00 – 🍹 Small break 20:15 – 📢 Event-Driven Microservices - Beyond the Fairy Tale 21:00 – 🙋‍♀️ drinks 22:00 – 🍻 See you next time? Details: Getting Your Axe On - Event Sourcing with Axon Framework In this presentation, we will explore the basics of event-driven architecture using Axon Framework. We'll start by explaining key concepts such as Event Sourcing and Command Query Responsibility Segregation (CQRS), and how they can improve the scalability and maintainability of modern applications. You will learn what Axon Framework is, how it simplifies implementing these patterns, and see hands-on examples of setting up a project with Axon Framework and Spring Boot. Whether you are new to these concepts or looking to understand them more, this session will provide practical insights and tools to help you build resilient and efficient applications. Event-Driven Microservices - Beyond the Fairy Tale Our applications need to be faster, better, bigger, smarter, and more enjoyable to meet our demanding end-users needs. In recent years, the way we build, run, and operate our software has changed significantly. We use scalable platforms to deploy and manage our applications. Instead of big monolithic deployment applications, we now deploy small, functionally consistent components as microservices. Problem. Solved. Right? Unfortunately, for most of us, microservices, and especially their event-driven variants, do not deliver on the beautiful, fairy-tale-like promises that surround them.In this session, Allard will share a different take on microservices. We will see that not much has changed in how we build software, which is why so many “microservices projects” fail nowadays. What lessons can we learn from concepts like DDD, CQRS, and Event Sourcing to help manage the complexity of our systems? He will also show how message-driven communication allows us to focus on finding the boundaries of functionally cohesive components, which we can evolve into microservices should the need arise.

    | Coven of Wisdom - Utrecht

    Go to page for Mastering Event-Driven Design
  • The Leadership Meetup

    PLEASE RSVP SO THAT WE KNOW HOW MUCH FOOD WE WILL NEED What distinguishes a software developer from a software team lead? As a team leader, you are responsible for people, their performance, and motivation. Your output is the output of your team. Whether you are a front-end or back-end developer, or any other discipline that wants to grow into the role of a tech lead, RSVP for an evening of learning, delicious food, and the fusion of leadership and tech! 🚀 18:00 – 🚪 Doors open to the public 18:15 – 🍕 Let’s eat 19:00 – 📢 First round of Talks 19:45 – 🍹 Small break 20:00 – 📢 Second round of Talks 20:45 – 🙋‍♀️ drinks 21:00 – 🍻 See you next time? First Round of Talks: Pixel Perfect and Perfectly Insane: About That Time My Brain Just Switched Off Remy Parzinski, Design System Lead at Logius Learn from Remy how you can care for yourself because we all need to. Second Round of Talks: Becoming a LeadDev at your client; How to Fail at Large (or How to Do Slightly Better) Arno Koehler Engineering Manager @ iO What are the things that will help you become a lead engineer? Building Team Culture (Tales of trust and positivity) Michel Blankenstein Engineering Manager @ iO & Head of Technology @ Zorggenoot How do you create a culture at your company or team? RSVP now to secure your spot, and let's explore the fascinating world of design systems together!

    | Coven of Wisdom - Amsterdam

    Go to page for The Leadership Meetup
  • Coven of Wisdom - Herentals - Spring `24 edition

    Join us for an exciting web technology meetup where you’ll get a chance to gain valuable insights and knowledge about the latest trends in the field. Don’t miss out on this opportunity to expand your knowledge, network with fellow developers, and discover new and exciting possibilities. And the best part? Food and drinks are on us! Johan Vervloet - Event sourced wiezen; an introduction to Event Sourcing and CQRS Join me on a journey into the world of CQRS and Event Sourcing! Together we will unravel the misteries behind these powerful concepts, by exploring a real-life application: a score app for the 'Wiezen' card game.Using examples straight from the card table, we will delve into the depths of event sourcing and CQRS, comparing them to more traditional approaches that rely on an ORM.We will uncover the signs in your own database that indicate where event sourcing can bring added value. I will also provide you with some tips and pointers, should you decide to embark on your own event sourcing adventure. Filip Van Reeth - WordPress API; "Are you talking to me?" What if the WordPress API could be one of your best friends? What kind of light-hearted or profound requests would it share with you? In this talk, I would like to introduce you to it and ensure that you become best friends so that together you can have many more pleasant conversations (calls). Wanna be friends? Please note that the event or talks will be conducted in Dutch. Want to give a talk? Send us your proposal at meetup.herentals@iodigital.com 18:00 - 19:00: Food/Drinks/Networking 19:00 - 21:00: Talks 21:00 - 22:00: Networking Thursday 30th of May, 18h00 - 22h00 CET iO Campus Herentals, Zavelheide 15, Herentals

    | Coven of Wisdom Herentals

    Go to page for Coven of Wisdom - Herentals - Spring `24 edition

Share