DEV Community

Cover image for Full Stack Reddit Clone - Spring Boot, React, Electron App - Part 4
MaxiCB
MaxiCB

Posted on • Edited on

Full Stack Reddit Clone - Spring Boot, React, Electron App - Part 4

Full Stack Reddit Clone - Spring Boot, React, Electron App - Part 4

Introduction

Welcome to Part 4 of creating a Reddit clone using Spring Boot, and React.

What are we building in this part?

  • User Information Service Implementation
  • Update Security Configuration
  • Authentication Response
  • Login Request DTO
  • Update Auth Service
  • Creation of Java Key Store
  • Creation of JWT
  • Login Logic
  • Login Endpoint

In Part 3 we created the registration and account verification logic!

Important Links

Part 1: User Information Service Implementation 😀

Let's cover the user service implementation class we will need. Inside com.your-name.backend create a new package called service, and add the following classes.

  • UserInformationServiceImpl: Is our interface that fetches user information from our PostgreSQL database.
package com.maxicb.backend.service;

import com.maxicb.backend.model.User;
import com.maxicb.backend.repository.UserRepository;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Collection;
import java.util.Collections;
import java.util.Optional;

@Service
@AllArgsConstructor
public class UserInformationServiceImpl implements UserDetailsService {
    UserRepository userRepository;

    private Collection<? extends GrantedAuthority> fetchAuths (String role) {
        return Collections.singletonList(new SimpleGrantedAuthority(role));
    }

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        Optional<User> optionalUser = userRepository.findByUsername(s);
        User user = optionalUser.orElseThrow(() -> new UsernameNotFoundException("No user found with username: " + s));

        return new org.springframework.security.core.userdetails.User(user.getUsername(),
                user.getPassword(),
                user.isAccountStatus(),
                true,
                true,
                true,
                fetchAuths("USER"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 2: Updated Security Configuration 🎟

Let's cover the security config class we will need to update. Inside com.your-name.backend.config update the following classes.

  • Security: Handles the security configuration for the whole application, handles encoding the password before storing it into the database, and fetching user information.
package com.maxicb.backend.config;

import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@EnableWebSecurity
@AllArgsConstructor
public class Security extends WebSecurityConfigurerAdapter {

    UserDetailsService userDetailsService;

    @Autowired
    public void configureGlobalConfig(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder.userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf().disable()
                .authorizeRequests()
                .antMatchers("/api/auth/**")
                .permitAll()
                .anyRequest()
                .authenticated();
    }

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

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 3: Authentication Response DTO

Let's cover the Authentication Response DTO class we will need. Inside com.your-name.backend.dto create the following class,

  • AuthResponse: Defines the data that our backend will send to the client for an authentication response.
package com.maxicb.backend.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class AuthResponse {
        private String authenticationToken;
        private String username;
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Login Request DTO

Let's cover the Login Request DTO class we will need. Inside com.your-name.backend.dto create the following class,

  • LoginRequest: Defines the data that our backend will recieve from the client during a login request.
package com.maxicb.backend.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class LoginRequest {
    private String username;
    private String password;
}
Enter fullscreen mode Exit fullscreen mode

Part 5: Create Java Key Store

Let's cover the java keystore we will need. Inside resources place the keystore you will create after you are done.

  • Java Keystore: You can refer to the oracle docs for creating a keystore Here
    • Key store generation command
  keytool -genkey -alias alias -keyalg RSA -keystore keystore.jks -keysize 2048
Enter fullscreen mode Exit fullscreen mode

Ensure you are in a easily acessible directory when you run this command as it will create the keystore there, and you will need to place it inside your project.
Ensure you keep track of the password, and alias that you use as you will need it later within the code base.

Part 6: Create JWT

Let's cover the JWT class we will need. Inside com.your-name.backend create a new package called security, and add the following class.

  • JWTProvider: Handles all of the logic for loading the keystore, and generating JWT based on that.
package com.maxicb.backend.security;

import com.maxicb.backend.exception.ActivationException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.security.*;
import java.security.cert.CertificateException;

import io.jsonwebtoken.Jwts;

@Service
public class JWTProvider {
    private KeyStore keystore;

    @PostConstruct
    public void init() {
        try {
            keystore = KeyStore.getInstance("JKS");
            InputStream resourceStream = getClass().getResourceAsStream("/keystore.jks");
            keystore.load(resourceStream, "password".toCharArray());
        } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) {
            throw new ActivationException("Exception occured while loading keystore");
        }
    }

    public String generateToken(Authentication authentication) {
        org.springframework.security.core.userdetails.User princ = (User) authentication.getPrincipal();
        return Jwts.builder().setSubject(princ.getUsername()).signWith(getPrivKey()).compact();
    }

    private PrivateKey getPrivKey () {
        try {
            return (PrivateKey) keystore.getKey("alias", "password".toCharArray());
        } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) {
            throw new ActivationException("Exception occurred while retrieving public key");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 7: Update Auth Service

Let's update out Authentication Service class to add login functionality. Inside com.your-name.backend.service update the following class.

  • AuthService: We are adding the login logic to our authentication service.
package com.maxicb.backend.service;

import com.maxicb.backend.dto.AuthResponse;
import com.maxicb.backend.dto.LoginRequest;
import com.maxicb.backend.dto.RegisterRequest;
import com.maxicb.backend.exception.ActivationException;
import com.maxicb.backend.model.AccountVerificationToken;
import com.maxicb.backend.model.NotificationEmail;
import com.maxicb.backend.model.User;
import com.maxicb.backend.repository.TokenRepository;
import com.maxicb.backend.repository.UserRepository;
import com.maxicb.backend.security.JWTProvider;
import lombok.AllArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.Optional;
import java.util.UUID;

import static com.maxicb.backend.config.Constants.EMAIL_ACTIVATION;

@Service
@AllArgsConstructor

public class AuthService {

    UserRepository userRepository;
    PasswordEncoder passwordEncoder;
    TokenRepository tokenRepository;
    MailService mailService;
    MailBuilder mailBuilder;
    AuthenticationManager authenticationManager;
    JWTProvider jwtProvider;

    @Transactional
    public void register(RegisterRequest registerRequest) {
        User user = new User();
        user.setUsername(registerRequest.getUsername());
        user.setEmail(registerRequest.getEmail());
        user.setPassword(encodePassword(registerRequest.getPassword()));
        user.setCreationDate(Instant.now());
        user.setAccountStatus(false);

        userRepository.save(user);

        String token = generateToken(user);
        String message = mailBuilder.build("Welcome to React-Spring-Reddit Clone. " +
                "Please visit the link below to activate you account : " + EMAIL_ACTIVATION + "/" + token);
        mailService.sendEmail(new NotificationEmail("Please Activate Your Account", user.getEmail(), message));
    }

    public AuthResponse login (LoginRequest loginRequest) {
        Authentication authenticate = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsername(), loginRequest.getPassword()));
        SecurityContextHolder.getContext().setAuthentication(authenticate);
        String authToken = jwtProvider.generateToken(authenticate);
        return new AuthResponse(authToken, loginRequest.getUsername());
    }

    private String encodePassword(String password) {
        return passwordEncoder.encode(password);
    }

    private String generateToken(User user) {
        String token = UUID.randomUUID().toString();
        AccountVerificationToken verificationToken = new AccountVerificationToken();
        verificationToken.setToken(token);
        verificationToken.setUser(user);
        tokenRepository.save(verificationToken);
        return token;
    }

    public void verifyToken(String token) {
        Optional<AccountVerificationToken> verificationToken = tokenRepository.findByToken(token);
        verificationToken.orElseThrow(() -> new ActivationException("Invalid Activation Token"));
        enableAccount(verificationToken.get());
    }

    public void enableAccount(AccountVerificationToken token) {
        String username = token.getUser().getUsername();
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new ActivationException("User not found with username: " + username));
        user.setAccountStatus(true);
        userRepository.save(user);
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 8: Login Endpoint

Let's update our Auth Controller class to add login endpoint. Inside com.your-name.backend.controller update the following class.

  • AuthController: Defines the different endpoints for registering, activating, and logging in a user.
package com.maxicb.backend.controller;

import com.maxicb.backend.dto.AuthResponse;
import com.maxicb.backend.dto.LoginRequest;
import com.maxicb.backend.dto.RegisterRequest;
import com.maxicb.backend.service.AuthService;
import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    AuthService authService;

    @PostMapping("/register")
    public ResponseEntity register(@RequestBody RegisterRequest registerRequest) {
        authService.register(registerRequest);
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @GetMapping("/verify/{token}")
    public ResponseEntity verify(@PathVariable String token) {
        authService.verifyToken(token);
        return new ResponseEntity<>("Account Activated", HttpStatus.OK);
    }

    @PostMapping("/login")
    public AuthResponse register(@RequestBody LoginRequest loginRequest) {
        return authService.login(loginRequest);
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion 🔍

  • To ensure everything is configured correctly you can run the application, and ensure there are no error in the console. Towards the bottom of the console you should see output similar to below

Alt Text

{
    "username": "test",
    "email": "test1@test.com",
    "password": "test12345"
}
Enter fullscreen mode Exit fullscreen mode
  • Once you recieve a 200 OK status back you can check you mailtrap.io inbox to find the activation email that was sent. The link should look similar to http://localhost:8080/api/auth/verify/{token}, be sure to omit the &lt from the end of the link. Navigation to the link will activate the account, and you should see "Account Activated" displayed as a response.

  • After activating your account you can test you login logic by sending a post request to http://localhost:8080/api/auth/login with the following data

{
    "username": "test",
    "password": "test12345"
}
Enter fullscreen mode Exit fullscreen mode
  • After logging in you should see a response similar to below
{
    "authenticationToken": {real_long_token},
    "username": "test"
}
Enter fullscreen mode Exit fullscreen mode
  • In this article we added our JWT token generation, login logic, and login endpoint.

Next Part 5

Top comments (4)

Collapse
 
lornmalvo profile image
LornMalvo

hello !

At the end when I try to connect I have the error:

"An exception occurred while retrieving the public key"

Any idea ?
Thanks

Collapse
 
maxicb profile image
MaxiCB

Hello!
It seems that the project cannot load the private key in the security package. Check to make sure that the alias and password parameters are the same ones used when generating the key. These parameters are used throughout the JWTProvider class created in step 6

Collapse
 
santosjordi profile image
Saint Jordi

Hello! When I try to login in the created user I get a "Key argument cannot be null." error 500.
The user was created and activated.
Any idea as to why is that?

Collapse
 
santosjordi profile image
Saint Jordi

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.IllegalArgumentException: Key argument cannot be null.] with root cause

java.lang.IllegalArgumentException: Key argument cannot be null.