DEV Community

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

Posted on • Updated on

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

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

Introduction

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

What are we building in this part?

  • JWT Validation
  • JWT Filtering
  • Update Auth Service
  • Subreddit DTO
  • Subreddit Service
  • READ Subreddit Endpoint
  • CREATE Subreddit Endpoint

In Part 4 we created the logic needed for JWT validation, updated our authentication service, and made our login endpoint's!

Important Links

Part 1: JWT Validation 🔐

Let's cover our JWT validation logic we will need. Inside com.your-name.backend.security we will update the following classes.

  • JWTProvider: Handles all of the logic for loading the keystore, gathering public/private keys, generating JWT, token validation, and gathering user information.
package com.maxicb.backend.security;

import com.maxicb.backend.exception.ActivationException;
import io.jsonwebtoken.Claims;
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;
import static io.jsonwebtoken.Jwts.parserBuilder;

@Service
public class JWTProvider {
    private KeyStore keystore;

    @PostConstruct
    public void init() {
        try {
            keystore = KeyStore.getInstance("JKS");
            InputStream resourceStream = getClass().getResourceAsStream("/reddit.jks");
            keystore.load(resourceStream, "secret".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();
    }

    public boolean validateToken (String token) {
        parserBuilder().setSigningKey(getPubKey()).build().parseClaimsJws(token);
        return true;
    }

    private PrivateKey getPrivKey () {
        try {
            return (PrivateKey) keystore.getKey("vox", "secret".toCharArray());
        } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException e) {
            throw new ActivationException("Exception occurred while retrieving public key");
        }
    }

    private PublicKey getPubKey () {
        try {
            return keystore.getCertificate("vox").getPublicKey();
        } catch(KeyStoreException e) {
            throw new ActivationException("Exception occurred retrieving public key");
        }
    }

    public String getNameFromJWT(String token) {
        Claims claims = parserBuilder()
                .setSigningKey(getPubKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
        return claims.getSubject();
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 2: JWT Filtering 🗄

Let's cover our JWT filtering logic we will need. Inside com.your-name.backend.security we will update the following class.

  • JWTAuthFilter: Handles loading JWT from request, and filtering.
package com.maxicb.backend.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
public class JWTAuthFilter extends OncePerRequestFilter {
    @Autowired
    private JWTProvider jwtProvider;
    @Qualifier("inMemoryUserDetailsManager")
    @Autowired
    private UserDetailsService userDetailsService;

    private String getTokenFromRequest (HttpServletRequest request) {
        String bearer = request.getHeader("Authorization");
        if(StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
            return bearer.substring(7);
        }
        return bearer;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
        String JWT = getTokenFromRequest(httpServletRequest);

        if (StringUtils.hasText(JWT) && jwtProvider.validateToken(JWT)) {
            String username = jwtProvider.getNameFromJWT(JWT);

            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities());
            auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 3: Updated Security Configuration 💂‍♀️

Let's cover our JWT validation logic we will need. Inside com.your-name.backend.config we will update the following class.

  • Security: We need to implement our newly created JWT Filter so that it will run first.
package com.maxicb.backend.config;

import com.maxicb.backend.security.JWTAuthFilter;
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;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@EnableWebSecurity
@AllArgsConstructor
public class Security extends WebSecurityConfigurerAdapter {

    UserDetailsService userDetailsService;
    JWTAuthFilter jwtAuthFilter;

    @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();
        httpSecurity.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
    }

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

    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 4: Updated Auth Service 💂‍♀️

Let's cover our JWT validation logic we will need. Inside com.your-name.backend.service we will update the following class.

  • AuthService: We need to implement logic to get the current user information.
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.core.userdetails.UsernameNotFoundException;
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));
    }

    @Transactional(readOnly = true)
    public User getCurrentUser() {
        org.springframework.security.core.userdetails.User principal = (org.springframework.security.core.userdetails.User) SecurityContextHolder.
                getContext().getAuthentication().getPrincipal();
        return userRepository.findByUsername(principal.getUsername())
                .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + principal.getUsername()));
    }

    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 5: Subreddit DTO 📨

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

  • SubredditDTO: Defines the data that our backend will send/receive from the client during a subreddit requests.
package com.maxicb.backend.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class SubredditDTO {
    private Long id;
    private String name;
    private String description;
    private Integer postCount;
    private Integer memberCount;
}

Enter fullscreen mode Exit fullscreen mode

Part 6: Subreddit Exception 🚫

Let's cover the subreddit not found exception our application will have. Inside com.your-name.backend.exception add the following class.

  • SubredditNotFoundException: Our custom exception handler for subreddit not found errors.
package com.maxicb.backend.exception;

public class SubredditNotFoundException extends RuntimeException {
    public SubredditNotFoundException(String message) {
        super(message);
    }
}
Enter fullscreen mode Exit fullscreen mode

Part 6: Subreddit Service 🌎

Let's cover the subreddit service our application will have. Inside com.your-name.backend.services add the following class.

  • SubredditService: Hold the logic for mapping data to and from DTO, getting all subreddits, getting specific subreddits, and adding subreddits.
package com.maxicb.backend.service;

import com.maxicb.backend.dto.SubredditDTO;
import com.maxicb.backend.exception.SubredditNotFoundException;
import com.maxicb.backend.model.Subreddit;
import com.maxicb.backend.repository.SubredditRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;

@Service
@AllArgsConstructor
public class SubredditService {

    private final SubredditRepository subredditRepository;
    private final AuthService authService;

    private SubredditDTO mapToDTO (Subreddit subreddit) {
        return SubredditDTO.builder()
                .id(subreddit.getId())
                .name(subreddit.getName())
                .description(subreddit.getDescription())
                .postCount(subreddit.getPosts().size())
                .build();

    }

    private Subreddit mapToSubreddit (SubredditDTO subredditDTO) {
        return Subreddit.builder().name("/r/" + subredditDTO.getName())
                .description(subredditDTO.getDescription())
                .user(authService.getCurrentUser())
                .creationDate(Instant.now())
                .build();

    }

    @Transactional(readOnly = true)
    public List<SubredditDTO> getAll() {
        return StreamSupport
                .stream(subredditRepository.findAll().spliterator(), false)
                .map(this::mapToDTO)
                .collect(Collectors.toList());
    }

    @Transactional
    public SubredditDTO save(SubredditDTO subredditDTO) {
        Subreddit subreddit = subredditRepository.save(mapToSubreddit(subredditDTO));
        subredditDTO.setId(subreddit.getId());
        return subredditDTO;
    }

    @Transactional(readOnly = true)
    public SubredditDTO getSubreddit(Long id) {
        Subreddit subreddit = subredditRepository.findById(id)
                .orElseThrow(() -> new SubredditNotFoundException("Subreddit not found with id -" + id));
        return mapToDTO(subreddit);
    }
}

Enter fullscreen mode Exit fullscreen mode

Part 7: READ && CREATE Subreddit Endpoint's 🌐

Let's cover the subreddit controller our application will have. Inside com.your-name.backend.controller add the following class.

  • SubredditController: Hold the logic for fetching creating subreddits, fetching all subreddits, and specific subreddits.
package com.maxicb.backend.controller;

import com.maxicb.backend.dto.SubredditDTO;
import com.maxicb.backend.service.SubredditService;
import lombok.AllArgsConstructor;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.util.List;

@RestController
@RequestMapping("/api/subreddit")
@AllArgsConstructor
public class SubredditController {

    SubredditService subredditService;

    @GetMapping
    public List<SubredditDTO> getAllSubreddits () {
        return subredditService.getAll();
    }

    @GetMapping("/{id}")
    public SubredditDTO getSubreddit(@PathVariable Long id) {
        return subredditService.getSubreddit(id);
    }

    @PostMapping
    public SubredditDTO addSubreddit(@RequestBody @Valid SubredditDTO subredditDTO) {
        return subredditService.save(subredditDTO);
    }
}

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
  • Add the token to the API testing tool you use as the authorization header, set as the Bearer Token type.

  • You can now send a POST request to http://localhost:8080/api/subreddit/ with the following data

{
    "name": "NAME",
    "description": "DESCRIPTION",
    "postCount": null
}
Enter fullscreen mode Exit fullscreen mode
  • You should recieve a 200 OK with the subreddit information back as a response, to ensure it was saved in the database you can send a GET request to http://localhost:8080/api/subreddit and you should recieve the following data
[
    {
        "id": 1,
        "name": "NAME",
        "description": "DESCRIPTION",
        "postCount": null
    }
]
Enter fullscreen mode Exit fullscreen mode
  • In this article we added our JWT validation, and filtering, updated our authentication services, and implemented the CREATE && READ endpoints for subreddit's.

Next

Part 6 is released, where we will cover the Create/Read operations for posts! If you have any questions be sure to leave a comment!

Discussion (0)