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
- 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: 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"));
}
}
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();
}
}
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;
}
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;
}
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
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");
}
}
}
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);
}
}
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);
}
}
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"
}
- In this article we added our JWT token generation, login logic, and login endpoint.
Top comments (4)
hello !
At the end when I try to connect I have the error:
"An exception occurred while retrieving the public key"
Any idea ?
Thanks
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
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?
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.