Spring WebFlux Security OTP Email with MongoDB and Flutter 1/2

In this post, we will see how to integrate Spring Security in a reactive Spring Webflux with MongoDB and Flutter.

Prerequisites

This is the list of all the prerequisites for following the part 1:

  • Java 17
  • Spring Boot / Starter WebFlux 2.6.7
  • Lombok 1.18
  • Maven 3.6.3
  • Postman
  • Mongo 4.4.

Overview

What is a One Time Password (OTP)?

one-time password (OTP), also known as a one-time PINone-time authorization code (OTAC), or dynamic password, is a password that is valid for only one login session or transaction, on a computer system or other digital device. — https://en.wikipedia.org/wiki/One-time_password

What is the benefit of using OTP?

The most important advantage addressed by OTPs is that, in contrast to static passwords, they are not vulnerable to replay attacks. This means that a potential intruder who manages to record an OTP that was already used to log into a service or to conduct a transaction will not be able to use it, since it will no longer be valid.

Getting Started

We will implement token-based authentication and authorization using JSON Web Token (JWT) provider with Spring WebFlux security.

The authentication flow is described by the following diagram:

We will start by creating a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Reactive Web, Spring Security, Spring Data Reactive MongoDB NoSQL, Java Mail Sender, Thymeleaf, Lombok, and Validation.

Below are the maven project dependencies:

 <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>io.projectreactor</groupId>
            <artifactId>reactor-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>

        <!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-webflux-core</artifactId>
            <version>${springdoc.openapi.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-webflux-ui</artifactId>
            <version>${springdoc.openapi.version}</version>
        </dependency>

        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${org.mapstruct.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${io.jsonwebtoken.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${io.jsonwebtoken.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <scope>compile</scope>
            <version>${io.jsonwebtoken.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
    </dependencies>

First, Let’s create a User POJO class that implements UserDetails. We use the annotation @Document to set the collection name that will be used by the model.


/**
 * A user.
 */
@Builder
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(collection = "app_user")
public class User implements UserDetails {

    @Serial
    private static final long serialVersionUID = 1L;

    @Id
    private String id;

    @NotNull
    @Size(min = 1, max = 50)
    @Indexed(unique = true)
    private String username;

    @Getter(onMethod = @__(@JsonIgnore))
    @NotNull
    @Size(min = 4, max = 60)
    private String password;

    @Size(max = 50)
    @Field("first_name")
    private String firstName;

    @Size(max = 50)
    @Field("last_name")
    private String lastName;

    @Email
    @Size(min = 5, max = 254)
    @Indexed
    private String email;

    @Field("otp_request")
    private OtpRequest otpRequest;

    @JsonIgnore
    private Set<Role> roles = new HashSet<>();


    private boolean enabled = false;

    private boolean accountNonExpired;

    private boolean credentialsNonExpired;

    private boolean accountNonLocked;


    @Override
    public boolean isEnabled() {
        return enabled;
    }

    @Override
    public boolean isAccountNonExpired() {
        return !accountNonExpired;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return !credentialsNonExpired;
    }

    @Override
    public boolean isAccountNonLocked() {
        return !accountNonLocked;
    }

    /*
     * Get roles and add them as a Set of GrantedAuthority
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roles.stream().map(r -> new SimpleGrantedAuthority(r.getName())).collect(Collectors.toSet());
    }

}

Our User class contains the embedded modelsOtpRequest et Role that manage OTP codes and user permissions.

Implement ReactiveUserDetailsService

The ReactiveUserDetailsService defines a method used by Spring Security to retrieve a user by its username that returns a Mono<UserDetails>.

We’ll create a class called UserDetailsService that overrides the method findByUsername() of the ReactiveUserDetailsService interface.
In this method, we retrieve the User object using the UserRepository if it exists.

@Slf4j
@RequiredArgsConstructor
@Component("userDetailsService")
public class UserDetailsService implements ReactiveUserDetailsService{
    private final AccountStatusUserDetailsChecker detailsChecker = new AccountStatusUserDetailsChecker();
    private final UserRepository userRepository;

    @Override
    public Mono<UserDetails> findByUsername(final String login) {
        LOGGER.debug("Authenticating with {}", login);
        String username = StringUtils.trimToNull(login.toLowerCase());

        if (new EmailValidator().isValid(username, null)) {
            return userRepository
                    .findOneByEmailIgnoreCase(username)
                    .switchIfEmpty(Mono.error(new UsernameNotFoundException(MessageFormat.format("User with email {0} was not found.", username))))
                    .map(this::getUserDetails);
        }
        return userRepository
                .findOneByUsernameIgnoreCase(username)
                .switchIfEmpty(Mono.error(new UsernameNotFoundException(MessageFormat.format("User {0} was not found", username))))
                .map(this::getUserDetails);
    }


    private UserDetails getUserDetails(User user) {
        detailsChecker.check(user);
        return user;
    }

}

Configure Spring Security

In the configuration package, create SecurityConfiguration class that will manage all security aspects.

@EnableWebFluxSecurity
@EnableReactiveMethodSecurity
public class SecurityConfiguration {

    private final String[] permitAllPatterns = new String[] {"/api/user/login","/api/user/register"};
    private final ReactiveUserDetailsService userDetailsService;

    private final TokenProvider tokenProvider;
    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;


    public SecurityConfiguration(ReactiveUserDetailsService userDetailsService, TokenProvider tokenProvider, CustomAccessDeniedHandler accessDeniedHandler, CustomAuthenticationEntryPoint authenticationEntryPoint) {
        this.userDetailsService = userDetailsService;
        this.tokenProvider = tokenProvider;
        this.accessDeniedHandler = accessDeniedHandler;
        this.authenticationEntryPoint = authenticationEntryPoint;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public ReactiveAuthenticationManager reactiveAuthenticationManager() {
        UserDetailsRepositoryReactiveAuthenticationManager authenticationManager = new UserDetailsRepositoryReactiveAuthenticationManager(
                userDetailsService
        );
        authenticationManager.setPasswordEncoder(passwordEncoder());
        return authenticationManager;
    }

    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        // @formatter:off
        http
                .securityMatcher(new NegatedServerWebExchangeMatcher(new OrServerWebExchangeMatcher(
                        pathMatchers("/webjars/**","/app/**", "/i18n/**", "/content/**", "/swagger-ui/**", "/v3/api-docs/**", "/test/**"),
                        pathMatchers(HttpMethod.OPTIONS, "/**")
                )))
                .csrf()
                .disable()

                // Add JWT token filter
                .addFilterAt(new SecurityContextFilter(tokenProvider), SecurityWebFiltersOrder.HTTP_BASIC)
                .authenticationManager(reactiveAuthenticationManager())

                // Set unauthorized requests exception handler
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPoint)
                .accessDeniedHandler(accessDeniedHandler)
                .and()
                .headers()
                .contentSecurityPolicy(AppConstant.DEFAULT_SRC_SELF_POLICY)
                .and()
                .referrerPolicy(ReferrerPolicyServerHttpHeadersWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
                .and()
                .permissionsPolicy().policy(AppConstant.PERMISSION_POLICY)
                .and()
                .frameOptions().mode(Mode.DENY)
                .and()

                // Set permissions on endpoints
                .authorizeExchange()
                .pathMatchers(permitAllPatterns).permitAll()
                .pathMatchers("/api/user/**").authenticated();
        // @formatter:on
        return http.build();
    }
}

Let me explain the code above.

– @EnableWebFluxSecurity allows WebFlux support in Spring Security.

– @EnableReactiveMethodSecurityallows method security supports in a reactive application and then uses method-level annotations, such as @PreAuthorize(“isAuthenticated()”)

– passwordEncoder()bean method for encoding passwords

– reactiveauthenticationManager()bean method that uses a ReactiveUserDetailsService to validate the provided username and password.

– springSecurityFilterChain(ServerHttpSecurity http) bean method with ServerHttpSecurity as the parameter. ServerHttpSecurity is similar to Spring Security’s HttpSecurity but for WebFlux. It allows configuring web-based security for specific HTTP requests.

WebFlux Handler and Router Functions

Now, let’s implement the router and manager functions. First, we will create the AccountHandler class which contains login(), register(), otpCheckCode(), otpResendCode()methods.

@Component
@RequiredArgsConstructor
public class AccountHandler {

    private final UserService userService;
    private final Validator validator;
    private final ReactiveAuthenticationManager authenticationManager;

    @PreAuthorize("hasRole('ROLE_USER') AND hasRole('ROLE_ADMIN')")
    public Mono<ServerResponse> isAuthenticated(ServerRequest serverRequest) {

        return serverRequest.principal()
                .map(Principal::getName)
                .flatMap(user ->
                        ServerResponse.status(HttpStatus.OK)
                                .bodyValue(new ApiResponseDTO(user, "Current user is authenticated")));
    }

    @PreAuthorize("hasRole('PRE_AUTH')")
    public Mono<ServerResponse> optCheckCode(ServerRequest serverRequest) {

        var otpCode = serverRequest.pathVariable("code");

        return serverRequest.principal()
                .map(Principal::getName)
                .flatMap(u -> userService.checkCode(u, otpCode))
                .flatMap(token ->
                        ServerResponse.status(HttpStatus.OK)
                                .bodyValue(new ApiResponseDTO(token, "Otp checking success")));
    }

    @PreAuthorize("hasRole('PRE_AUTH')")
    public Mono<ServerResponse> optResendCode(ServerRequest serverRequest) {

        return serverRequest.principal()
                .map(Principal::getName)
                .flatMap(userService::resendCode)
                .flatMap(token ->
                        ServerResponse.status(HttpStatus.OK)
                                .bodyValue(new ApiResponseDTO(token, "Otp checking success")));
    }


    public Mono<ServerResponse> login(final ServerRequest request) {
        return request.bodyToMono(LoginDTO.class)
                .flatMap(body ->
                        validator.validate(body).isEmpty()
                                ? Mono.just(body)
                                : Mono.error(new ValidatorException(validator.validate(body).stream().map(ConstraintViolation::getMessage).toList().toString()))
                )
                .flatMap(login ->
                        authenticationManager
                                .authenticate(new UsernamePasswordAuthenticationToken(login.getUsername(), login.getPassword()))
                                .flatMap(userService::setUserOtp)
                )
                .flatMap(jwt ->
                        ServerResponse.status(HttpStatus.OK)
                                .bodyValue(new ApiResponseDTO(jwt.token(), "User login success")));
    }

    public Mono<ServerResponse> register(final ServerRequest request) {

        return request.bodyToMono(UserPasswordDTO.class)
                .flatMap(body ->
                        validator.validate(body).isEmpty()
                        ? Mono.just(body)
                        : Mono.error(new ValidatorException(validator.validate(body).stream().map(ConstraintViolation::getMessage).toList().toString()))
                )
                .flatMap(userService::createUser)
                .flatMap(savedUser ->
                        ServerResponse.status(HttpStatus.CREATED)
                                .bodyValue(new ApiResponseDTO(savedUser, "User created successfully")));
    }

}

The handle functional method accepts ServerRequest and returns Mono<ServerResponse>.

Incoming requests are routed to a handler function with a RouterFunction.

@Configuration
@AllArgsConstructor
public class AccountRouter {



    @RouterOperations({
            @RouterOperation(path = "/api/user/authenticate", produces = {MediaType.APPLICATION_JSON_VALUE },
                    beanClass = AccountHandler.class,  beanMethod = "isAuthenticated",
                    operation = @Operation(operationId = "isAuthenticated",
                            responses = {
                            @ApiResponse(responseCode = "200", description = "get current user.", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class)))
                    })
            ),
            @RouterOperation(path = "/api/user/register", produces = {MediaType.APPLICATION_JSON_VALUE },
                    beanClass = AccountHandler.class,  beanMethod = "register",
                    operation = @Operation(operationId = "register",
                            responses = {
                            @ApiResponse(responseCode = "201", description = "User account created.", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "404", description = "User account already exist", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "400", description = "Bad request", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "500", description = "Something went wrong", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                    }, requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = UserPasswordDTO.class )))
                    )
            ),
            @RouterOperation(path = "/api/user/login", produces = {MediaType.APPLICATION_JSON_VALUE },
                    beanClass = AccountHandler.class,  beanMethod = "login",
                    operation = @Operation(operationId = "login",
                            responses = {
                            @ApiResponse(responseCode = "200", description = "User account created.", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "404", description = "User account already exist", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "400", description = "Bad request", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "500", description = "Something went wrong", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                    }, requestBody = @RequestBody(content = @Content(schema = @Schema(implementation = LoginDTO.class )))
                    )
            ),
            @RouterOperation(path = "/api/user/otp/{code}", produces = {MediaType.APPLICATION_JSON_VALUE },
                    beanClass = AccountHandler.class,  beanMethod = "optCheckCode",
                    operation = @Operation(operationId = "optCheckCode",
                            responses = {
                            @ApiResponse(responseCode = "200", description = "OTP success.", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "404", description = "User account not found", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "400", description = "Bad request", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "401", description = "Unauthorized", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class))),
                            @ApiResponse(responseCode = "500", description = "Something went wrong", content = @Content(schema = @Schema(implementation = ApiResponseDTO.class)))
                    }, parameters = {@Parameter(in = ParameterIn.PATH, name = "code") }
                    )
            ),
    })
    @Bean
    public RouterFunction<ServerResponse> routeUserAccount(final AccountHandler accountHandler) {

        return route()
                .nest(path("/api/user"), builder ->
                        builder
                                .GET("/authenticate", accountHandler::isAuthenticated)
                                .GET("/resend/code", accountHandler::optResendCode)
                                .POST("/register", accountHandler::register)
                                .POST("/login", accountHandler::login)
                                .GET("/otp/{code}", accountHandler::optCheckCode))
                .build();
    }

}

Run & Test

Run the app.

Open http://localhost:8080/webjars/swagger-ui/index.html URL in a web browser.

swagger documentation page

It works so far 👌

The first step is to create a new user.

We log in with the username and password of the user created previously.

When the information is correct, we will create a temporary token (10 min) with the following message: Partially successful user login — an OTP code has been sent to your email address

The content of the email is as follows:

Continue reading

In the second part, we implemented the whole process with a mobile application using Flutter and Dart.

The complete backend source code can be found in my GitHub repository.

If you enjoyed this article, please give it a few claps for support.

Happy coding 🙂.

References

👉 Link to Medium blog

Related Posts