Spring boot, angular and JWT authentication

blog-post-img

In preparation for a potential customer project, I had to refresh my knowledge of Angular. The best way to do that is to create a small demo application. I used Angular 15 with Spring Boot 3 and JWT to secure the REST API. Often an authorization server like Keycloak is used for authorization and token generation. However, I tried to keep the example as simple as possible and generate the JWT as part of the application.

You’ll find the demo project on GitHub: https://github.com/simasch/angular-springboot-demo.

Backend setup

The backend code is written in Java and uses Spring Boot 3.

Dependencies and Configuration

The code for the backend is placed in the backend folder. It’s a Maven project with the following dependencies:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
</dependency>

The critical dependency is spring-boot-starter-oauth2-resource-server which enables OAuth 2.0 resource server that protects resources with OAuth tokens, JWT in this case.

Spring Security Configuration

With newer Spring Security versions HttpSecurity is configured using SecurityFilterChain and no longer extend WebSecurityConfigurerAdapater. The configuration is adapted from a Spring Security example on GitHub. Special thanks to Marcus Hert da Coregio from the Spring Security team for the pointer to the repo and answering my questions during the development of the example.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
	.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
	.csrf((csrf) -> csrf.ignoringRequestMatchers("/api/auth"))
	.httpBasic(Customizer.withDefaults())
	.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
	.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
	.exceptionHandling((exceptions) -> exceptions
		.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
		.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
	);
    return http.build();
}

On line 5 CSRF is enabled. This is not necessary because of the use of stateless authentication. But If the user adds some authentication mechanism that relies on a session cookie, they do not have to remember to change the CSRF configuration. Line 7 configures the OAuth2 resource server with JWT, and on line 8 the session is defined as stateless. We also configure basic authentication. This is used for the endpoint that serves the token.

For the sake of simplicity, an in-memory user is created. In a real-world project, the user would be stored in a database, an LDAP directory, or a similar.

@Bean
UserDetailsService users() {
    return new InMemoryUserDetailsManager(
            User.withUsername("user")
                    .password(passwordEncoder().encode("pass"))
                    .roles(Roles.USER)
                    .build()
    );
}

Finally, the JWT handling must be configured. We define encode and decoder using asymmetric keys. That means the private key is used to encode the token and the public key to decode.

@Bean
JwtDecoder jwtDecoder() {
    return NimbusJwtDecoder.withPublicKey(this.key).build();
}

@Bean
JwtEncoder jwtEncoder() {
    JWK jwk = new RSAKey.Builder(this.key).privateKey(this.priv).build();
    JWKSource<SecurityContext> jwks = new ImmutableJWKSet<>(new JWKSet(jwk));
    return new NimbusJwtEncoder(jwks);
}

As you’ll see later, the JWT contains a claim “scope” that contains the user roles. By default, the JWT decoder adds a SCOPE_ suffix to the role names, and we want to avoid that. Therefore we have to set the authority prefix to an empty string (line 5 below).

@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
    JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
    // Remove the SCOPE_ prefix
    grantedAuthoritiesConverter.setAuthorityPrefix("");

    JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
    jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
    return jwtAuthenticationConverter;
}

Token Generation

To generate the JWT a REST endpoint with Basic authentication is used. On line 23 the user roles are set to the scope claim. Noteworthy is setting the issued time and when the token will expire (line 20, 21). The Authentication object passed to the endpoint method will contain all the necessary user information.

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final JwtEncoder encoder;

    public AuthController(JwtEncoder encoder) {
        this.encoder = encoder;
    }

    @PostMapping("")
    public String auth(Authentication authentication) {
        Instant now = Instant.now();
        long expiry = 36000L;
        String scope = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("self")
                .issuedAt(now)
                .expiresAt(now.plusSeconds(expiry))
                .subject(authentication.getName())
                .claim("scope", scope)
                .build();
        return this.encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }
}

REST API with Role-based Access Control

In the example, a Person REST API provides CRUD operations on the Person entity. For simplicity, the Person entity is stored in an H2 database. There is a PersonPopulator that inserts two persons (Ginger Rogers and Fred Astaire) when the application starts. On line 1 the RolesAllowed annotation is used to restrict access to users with the role USER. Thanks, Lucas Pradel for pointing this out.

@RolesAllowed(Roles.USER)
@RestController
@RequestMapping("/api/persons")
public class PersonController {

    private final PersonService personService;

    public PersonController(PersonService personService) {
        this.personService = personService;
    }

    @GetMapping
    public List<Person> getAll() {
        return personService.findAll();
    }

    @GetMapping("/{id}")
    public Person getById(@PathVariable Integer id) {
        return personService.findById(id).orElseThrow();
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void post(@RequestBody Person person) {
        personService.save(person);
    }
}

Frontend (Angular 15)

In the frontend, Angular 15 and TypeScript are used, and the code can be found in the frontend folder of the project.

Authorization

First, let’s have a look at the login component. The login() method calls the AuthService which issues a call to the token endpoint with basic authentication. It subscribes to get the token and first stores it in the session storage. Then extracts the roles and stores them in the session storage as well.

public login(): void {
    sessionStorage.removeItem("app.token");

    this.authService.login(this.username, this.password)
        .subscribe({
            next: (token) => {
                sessionStorage.setItem("app.token", token);
                const decodedToken = jwtDecode<JwtPayload>(token);
                // @ts-ignore
                sessionStorage.setItem("app.roles",  decodedToken.scope);
                this.router.navigateByUrl("/persons");
            },
            error: (error) => this.snackBar.open(`Login failed: ${error.status}`, "OK")
        });
}

The demo application uses Angular Material, and when an error occurs, we use the Snackbar component to display the error message.

That’s how the login method looks like in the AuthService:

login(username: string, password: string): Observable<string> {
    const httpOptions = {
        headers: {
            Authorization: 'Basic ' + window.btoa(username + ':' + password)
        },
        responseType: 'text' as 'text',
    };
    return this.http.post("/api/auth", null, httpOptions);
}

Now that we have received and stored the token we need to pass the token in each subsequent REST call. That can be done with an HttpInterceptor. The token is read from the session storage and added to the Authorization head of the request. It also catches errors, and if the error is a 401 Unauthorized it redirects to the login page.

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    let token = sessionStorage.getItem("app.token");
    if (token) {
        request = request.clone({
            setHeaders: {
                Authorization: `Bearer ${token}`
            },
        });
    }

    return next.handle(request).pipe(
        catchError((error: HttpErrorResponse) => this.handleErrorRes(error))
    );
}

private handleErrorRes(error: HttpErrorResponse): Observable<never> {
    if (error.status === 401) {
        this.router.navigateByUrl("/login", {replaceUrl: true});
    }
    return throwError(() => error);
}

Role-based Security

An Angular guard was created to check if the user is logged in and has the correct role to access the views. The guard implements CanActive. If the user is not logged in or lacks the needed role, it redirects to the login page.

public canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): boolean {
    if (this.authService.isLoggedIn() && this.authService.isUserInRole(next.routeConfig?.data?.['role'])) {
        return true;
    } else {
        this.router.navigateByUrl("/login");
        return false;
    }
}

The interesting part is the isUserInRole method of the AuthService. The role is defined on the route and is passed to the method. First, the roles are fetched from the session storage and then checked against the passed role.

isUserInRole(roleFromRoute: string) {
    const roles = sessionStorage.getItem("app.roles");

    if (roles!.includes(",")) {
        if (roles === roleFromRoute) {
            return true;
        }
    } else {
        const roleArray = roles!.split(",");
        for (let role of roleArray) {
            if (role === roleFromRoute) {
                return true;
            }
        }
    }
    return false;
}

In the route definition, the roles are configured in the data property.

const routes: Routes = [
    {path: '', component: PersonComponent, 
           canActivate: [AuthGuard], data: {role: 'ROLE_USER'}},
    {path: 'persons', component: PersonComponent, 
           canActivate: [AuthGuard], data: {role: 'ROLE_USER'}},
    {path: 'persons/:id', component: PersonEditComponent, 
           canActivate: [AuthGuard], data: {role: 'ROLE_USER'}},
    {path: 'login', component: LoginComponent}
];

Conclusion

The advantage of working with popular frameworks is that you can find a lot of information about them. However, since the frameworks now have a very rapid release cycle, the flood of information is also a problem at the same time because the information becomes outdated very quickly. You must filter out the one that fits your version from a large heap of information.
This is also the reason for this post, and I hope that this will be helpful to others. Run into any roadblocks? I’m here to assist you – feel free to contact me!

Related Posts

Simon Martinelli
Follow Me