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
- Backend Source: https://github.com/MaxiCB/vox-nobis/tree/master/backend
- Frontend Source: https://github.com/MaxiCB/vox-nobis/tree/master/client
- Live URL: In Progress
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();
}
}
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);
}
}
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();
}
}
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);
}
}
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;
}
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);
}
}
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);
}
}
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);
}
}
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
- If there are no error's in the console you can test you registration logic by sending a post request to http://localhost:8080/api/auth/register with the following data
{
"username": "test",
"email": "test1@test.com",
"password": "test12345"
}
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 < 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"
}
- After logging in you should see a response similar to below
{
"authenticationToken": {real_long_token},
"username": "test"
}
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
}
- 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
}
]
- In this article we added our JWT validation, and filtering, updated our authentication services, and implemented the CREATE && READ endpoints for subreddit's.
Top comments (0)