Skip to content

Security

dkettner edited this page May 23, 2023 · 22 revisions

Why Spring Security?

While there is no way to ensure total security (especially on the web), relying on industry standards and best practices should be the first step to securing software. Still, implementing all these by oneself would probably result in messy code with lots of bugs (-> security risks). To avoid having to deal with multiple iterations of not so secure code I chose to rely on a framework to do the heavy lifting for me.

The Spring Security framework does exactly that by establishing a customizable security filter chain.


Securing A RESTful API

Due to the nature of RESTful services (they should be stateless) a client cannot just provide its credentials and "keep the connection alive" with a kind of session. Each HTTP request is essentially independent.

To conquer this problem and to avoid having to send the credentials again with every request, the following process has become a common practice:

  1. Users provide their credentials (mail address and password).
  2. The service authenticates the user and sends back a unique token that respresents the user's identity.
  3. The user sends the token with each request and the service can check if the token is legit and also what the owner of this token is allowed to do (authorization).

About the token: Most commonly it will be a JWT. It is a special kind of token which gets created with a private key and can be verified with a public key. This ensures that a JWT cannot be forged. A JWT also contains 'claims'. For example the unique email address of the user might be a claim. After verifiying the authenticity of the JWT, the service provide all permissions to the user with the specified mail address.

Also, using the OAuth2 protocol is the de facto best practise. Among other benefits, it allows to use external services like Google or Github to handle authentication and the creation of tokens.


Encryption

Currently there is no encrypted transmission of data. There is a TLS Branch but it will still take some time. Please read about the related problems in the FAQ. TLDR: Getting valid CA-certificates for privately hosted systems is difficult.


The Chosen Approach

Because of the above mentioned reasons, the Ticketing System uses the Spring Security Framework and JWTs: Users send HTTP requests to 'POST /authentications' and will receive JWTs. (No authentication needed for creating an account. This can be configured in the filter chains of Spring Security.)

There are multiple ways to add JWTs to HTTP requests with the most secure option being a secure cookie that gets set by the backend and cannot be read by the browser. This cookie gets added automatically with each request to the backend. However, this option is currently not viable because modern browsers only accept secure cookies from verified sources. This means that without TLS encryption it is not possible.

Instead the Ticketing System uses a different but still widely used approach: Setting a bearer token ("Bearer " + JWT) in the authorization header of the HTTP request. The obvious disadvantage is that the bearer token can be read from the headers if the request gets intercepted.

Also, it does not use OAuth2 because another security measure is that the system is meant to be hosted on a private machine in a local network (reducing most security risks). So no public hosting and no dependency on external services is allowed.

Future iterations will aim to be secure enough to get hosted publicly. These will use all the security measures which cannot be used right now: TLS encryption, secure cookies instead of bearer tokens and OAuth2.


Implementing Authorization

Upon receiving a request to a protected endpoint, the backend validates the token and on success it collects all authorities of the user who is specified in the email-claim of the JWT. These authorities are accessable during the transaction and can be used to check if the user has the necessary permissions. This happens on every incoming request and also ensures that the authorities are always up-to-date, even if a new authority gets added after the user received their JWT.

Every user has at least one authority: 'ROLE_USER_XXXXXXX' with the UUID of the user at the end (authorities need to start with 'ROLE_'). Also for every accepted membership, another authority gets added which is based on their role within the project: 'ROLE_PROJECT_MEMBER_YYYYYYY' or 'ROLE_PROJECT_ADMIN_YYYYYYYY' with the UUID of the project at the end.

With these three types of authorities each aggregate may check if a user has the necessary permissions. This always happens inside the application service before any changes in the domain may get triggered. The methods in the application services are annoted with @PreAuthorize which accepts a condition that must be fulfilled when calling the method. Otherwise the transaction gets aborted and the client receives either a 403 or 404 response.


The following examples will illustrate how the checks work:

1. Users may only delete themselves.

The hasAuthority-directive checks, if the user has the needed authority during this transaction. Using SpEL we can access the parameters of the method. So by reading the UUID of the user that shall be deleted, it is possible to construct the needed authority 'ROLE_USER_XXXXXXX'.

@PreAuthorize("hasAuthority('ROLE_USER_'.concat(#id))")
public void deleteUserById(UUID id) {
   userDomainService.deleteById(id);
}

(/user/application/UserApplicationService.java)

2. Project admins as well as project members may get the tickets of a project.

The hasAnyAuthority-directive checks, if the user has at least one of the listed authorities. For constructing the needed authorities we need the UUID of the project but in this example this UUID is not found in the parameter list. To still get his information, we may access the ticket domain service and get the projectId of the specified ticket.

@PreAuthorize("hasAnyAuthority(" +
   "'ROLE_PROJECT_ADMIN_'.concat(@ticketDomainService.getProjectIdByTicketId(#id)), " +
   "'ROLE_PROJECT_MEMBER_'.concat(@ticketDomainService.getProjectIdByTicketId(#id)))")
public TicketResponseDto getTicketById(UUID id) {
   Ticket ticket = ticketDomainService.getTicketById(id);
   return dtoMapper.mapTicketToTicketResponseDto(ticket);
}

(/ticket/application/TicketApplicationService.java)


It is possible to define complex conditions with other directives that access multiple services but debugging these has proven to be very difficult. This is why the Ticketing System tries to keep it simple by sticking to the following rules:

  1. Only use hasAuthority or hasAnyAuthority.
  2. Get the needed IDs by accessing the parameters of the method or make exactly one call to a service. This may require writing new methods in the service. Complex queries should not be written inside conditions (although it would be possible).
  3. The called methods may only read data. Avoid methods that may modify data before all required permissions have been checked.
  4. The service that gets called has to be part of the aggregate. In the second example the ticket domain service gets called and not the service of another aggregate. This also means that each aggregate should collect the needed data to perform its own security checks. Check out the events section to find out more about this.