From 61f5503bdb7046f60154c332ad657265c35f44c5 Mon Sep 17 00:00:00 2001 From: Vus Khoza Date: Thu, 28 Aug 2025 23:50:54 +0200 Subject: [PATCH] added jwt auth to service --- JWT_AUTH_README.md | 93 +++++++++++++++++++ pom.xml | 25 +++++ .../tm/configs/JwtAuthenticationFilter.java | 62 +++++++++++++ .../zw/qantra/tm/configs/SecurityConfig.java | 62 +++++++++++++ .../tm/domain/controllers/AuthController.java | 36 +++++++ .../tm/domain/controllers/TestController.java | 27 ++++++ .../qantra/tm/domain/dtos/AuthResponse.java | 16 ++++ .../qantra/tm/domain/dtos/LoginRequest.java | 13 +++ .../tm/domain/dtos/RegisterRequest.java | 30 ++++++ .../java/zw/qantra/tm/domain/models/User.java | 71 ++++++++++++++ .../domain/repositories/UserRepository.java | 16 ++++ .../tm/domain/services/UserService.java | 67 +++++++++++++ .../java/zw/qantra/tm/utils/JwtUtils.java | 72 ++++++++++++++ src/main/resources/application.properties | 4 + 14 files changed, 594 insertions(+) create mode 100644 JWT_AUTH_README.md create mode 100644 src/main/java/zw/qantra/tm/configs/JwtAuthenticationFilter.java create mode 100644 src/main/java/zw/qantra/tm/configs/SecurityConfig.java create mode 100644 src/main/java/zw/qantra/tm/domain/controllers/AuthController.java create mode 100644 src/main/java/zw/qantra/tm/domain/controllers/TestController.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/AuthResponse.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/LoginRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java create mode 100644 src/main/java/zw/qantra/tm/domain/models/User.java create mode 100644 src/main/java/zw/qantra/tm/domain/repositories/UserRepository.java create mode 100644 src/main/java/zw/qantra/tm/domain/services/UserService.java create mode 100644 src/main/java/zw/qantra/tm/utils/JwtUtils.java diff --git a/JWT_AUTH_README.md b/JWT_AUTH_README.md new file mode 100644 index 0000000..aeeb629 --- /dev/null +++ b/JWT_AUTH_README.md @@ -0,0 +1,93 @@ +# JWT Authentication Implementation + +This Spring Boot application now includes JWT-based authentication with user registration and login functionality. + +## Features Added + +- ✅ JWT token generation and validation +- ✅ User registration endpoint +- ✅ User login endpoint +- ✅ Protected endpoints requiring authentication +- ✅ Public endpoints accessible without authentication +- ✅ Password encryption using BCrypt +- ✅ User entity with Spring Security integration + +## API Endpoints + +### Public Endpoints (No Authentication Required) +- `GET /api/test/public` - Test public endpoint +- `POST /auth/register` - User registration +- `POST /auth/login` - User login +- `GET /auth/test` - Test authentication endpoints + +### Protected Endpoints (Authentication Required) +- `GET /api/test/protected` - Test protected endpoint +- All other endpoints in the application + +## Testing the Authentication + +### 1. Register a New User +```bash +curl -X POST http://localhost:6950/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "password": "password123", + "email": "test@example.com", + "phone": "+263771234567", + "firstName": "Test", + "lastName": "User" + }' +``` + +### 2. Login with the User +```bash +curl -X POST http://localhost:6950/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "username": "testuser", + "password": "password123" + }' +``` + +### 3. Access Protected Endpoint +```bash +curl -X GET http://localhost:6950/api/test/protected \ + -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE" +``` + +### 4. Test Public Endpoint +```bash +curl -X GET http://localhost:6950/api/test/public +``` + +## Configuration + +The JWT configuration is in `application.properties`: +- `jwt.secret`: Secret key for signing JWT tokens +- `jwt.expiration`: Token expiration time in milliseconds (default: 24 hours) + +## Security Features + +- **Password Encryption**: All passwords are encrypted using BCrypt +- **JWT Tokens**: Stateless authentication using JSON Web Tokens +- **CORS Support**: Cross-origin requests are enabled for testing +- **Input Validation**: Request validation using Bean Validation annotations +- **Stateless Sessions**: No server-side session storage +- **Direct Password Validation**: Login uses direct password comparison for simplicity + +## Database + +The application will automatically create the `users` table when it starts up. The table includes: +- User credentials (username, password, email) +- Personal information (first name, last name) +- Account status flags +- Timestamps for creation and updates + +## Notes + +- In production, change the `jwt.secret` to a secure, randomly generated key +- The default JWT expiration is set to 24 hours +- All passwords must be at least 6 characters long +- Usernames and emails must be unique +- The application uses PostgreSQL as the database diff --git a/pom.xml b/pom.xml index e2ad341..66f906d 100644 --- a/pom.xml +++ b/pom.xml @@ -108,6 +108,31 @@ specification-arg-resolver 3.1.0 + + org.springframework.boot + spring-boot-starter-security + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + org.springframework.boot + spring-boot-starter-validation + diff --git a/src/main/java/zw/qantra/tm/configs/JwtAuthenticationFilter.java b/src/main/java/zw/qantra/tm/configs/JwtAuthenticationFilter.java new file mode 100644 index 0000000..515ffd4 --- /dev/null +++ b/src/main/java/zw/qantra/tm/configs/JwtAuthenticationFilter.java @@ -0,0 +1,62 @@ +package zw.qantra.tm.configs; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import zw.qantra.tm.domain.repositories.UserRepository; +import zw.qantra.tm.utils.JwtUtils; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtils jwtUtils; + private final UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + final String authHeader = request.getHeader("Authorization"); + final String jwt; + final String username; + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + jwt = authHeader.substring(7); + try { + username = jwtUtils.extractUsername(jwt); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); + + if (jwtUtils.validateToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + } catch (Exception e) { + // Token is invalid, continue without authentication + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/zw/qantra/tm/configs/SecurityConfig.java b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java new file mode 100644 index 0000000..799417b --- /dev/null +++ b/src/main/java/zw/qantra/tm/configs/SecurityConfig.java @@ -0,0 +1,62 @@ +package zw.qantra.tm.configs; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +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.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import zw.qantra.tm.domain.services.UserService; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, UserService userService) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/public/**").permitAll() + .anyRequest().authenticated() + ) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authenticationProvider(authenticationProvider(userService)) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationProvider authenticationProvider(UserService userService) { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java b/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java new file mode 100644 index 0000000..238c02c --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/controllers/AuthController.java @@ -0,0 +1,36 @@ +package zw.qantra.tm.domain.controllers; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import zw.qantra.tm.domain.dtos.AuthResponse; +import zw.qantra.tm.domain.dtos.LoginRequest; +import zw.qantra.tm.domain.dtos.RegisterRequest; +import zw.qantra.tm.domain.services.UserService; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +@CrossOrigin(origins = "*") +public class AuthController { + + private final UserService userService; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + AuthResponse response = userService.register(request); + return ResponseEntity.ok(response); + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + AuthResponse response = userService.login(request); + return ResponseEntity.ok(response); + } + + @GetMapping("/test") + public ResponseEntity test() { + return ResponseEntity.ok("Authentication endpoints are working!"); + } +} diff --git a/src/main/java/zw/qantra/tm/domain/controllers/TestController.java b/src/main/java/zw/qantra/tm/domain/controllers/TestController.java new file mode 100644 index 0000000..140e6af --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/controllers/TestController.java @@ -0,0 +1,27 @@ +package zw.qantra.tm.domain.controllers; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/test") +@CrossOrigin(origins = "*") +public class TestController { + + @GetMapping("/protected") + public ResponseEntity protectedEndpoint() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String username = authentication.getName(); + return ResponseEntity.ok("Hello " + username + "! This is a protected endpoint."); + } + + @GetMapping("/public") + public ResponseEntity publicEndpoint() { + return ResponseEntity.ok("This is a public endpoint - no authentication required!"); + } +} diff --git a/src/main/java/zw/qantra/tm/domain/dtos/AuthResponse.java b/src/main/java/zw/qantra/tm/domain/dtos/AuthResponse.java new file mode 100644 index 0000000..04779c1 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/AuthResponse.java @@ -0,0 +1,16 @@ +package zw.qantra.tm.domain.dtos; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class AuthResponse { + private String token; + private String type = "Bearer"; + private String username; + private String email; + private String phone; + private String firstName; + private String lastName; +} diff --git a/src/main/java/zw/qantra/tm/domain/dtos/LoginRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/LoginRequest.java new file mode 100644 index 0000000..5a97f7c --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/LoginRequest.java @@ -0,0 +1,13 @@ +package zw.qantra.tm.domain.dtos; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class LoginRequest { + @NotBlank(message = "Username is required") + private String username; + + @NotBlank(message = "Password is required") + private String password; +} diff --git a/src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java b/src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java new file mode 100644 index 0000000..7096cd1 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/dtos/RegisterRequest.java @@ -0,0 +1,30 @@ +package zw.qantra.tm.domain.dtos; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Data +public class RegisterRequest { + @NotBlank(message = "Username is required") + @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") + private String username; + + @NotBlank(message = "Password is required") + @Size(min = 6, message = "Password must be at least 6 characters") + private String password; + + @NotBlank(message = "Email is required") + @Email(message = "Email should be valid") + private String email; + + @NotBlank(message = "First name is required") + private String firstName; + + @NotBlank(message = "Last name is required") + private String lastName; + + @NotBlank(message = "Phone is required") + private String phone; +} diff --git a/src/main/java/zw/qantra/tm/domain/models/User.java b/src/main/java/zw/qantra/tm/domain/models/User.java new file mode 100644 index 0000000..b84f34a --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/models/User.java @@ -0,0 +1,71 @@ +package zw.qantra.tm.domain.models; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Entity +@Table(name = "users") +@Data +@EqualsAndHashCode(callSuper = true) +public class User extends BaseEntity implements UserDetails { + + @Column(unique = true, nullable = false) + private String username; + + @Column(nullable = false) + private String password; + + @Column(unique = true, nullable = false) + private String email; + private String phone; + + @Column(name = "first_name") + private String firstName; + + @Column(name = "last_name") + private String lastName; + + @Column(name = "is_enabled") + private boolean enabled = true; + + @Column(name = "is_account_non_expired") + private boolean accountNonExpired = true; + + @Column(name = "is_account_non_locked") + private boolean accountNonLocked = true; + + @Column(name = "is_credentials_non_expired") + private boolean credentialsNonExpired = true; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public boolean isAccountNonExpired() { + return accountNonExpired; + } + + @Override + public boolean isAccountNonLocked() { + return accountNonLocked; + } + + @Override + public boolean isCredentialsNonExpired() { + return credentialsNonExpired; + } + + @Override + public boolean isEnabled() { + return enabled; + } +} diff --git a/src/main/java/zw/qantra/tm/domain/repositories/UserRepository.java b/src/main/java/zw/qantra/tm/domain/repositories/UserRepository.java new file mode 100644 index 0000000..7fca923 --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/repositories/UserRepository.java @@ -0,0 +1,16 @@ +package zw.qantra.tm.domain.repositories; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import zw.qantra.tm.domain.models.User; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); + Optional findByPhone(String phone); + boolean existsByUsername(String username); + boolean existsByEmail(String email); +} diff --git a/src/main/java/zw/qantra/tm/domain/services/UserService.java b/src/main/java/zw/qantra/tm/domain/services/UserService.java new file mode 100644 index 0000000..0c6ed3e --- /dev/null +++ b/src/main/java/zw/qantra/tm/domain/services/UserService.java @@ -0,0 +1,67 @@ +package zw.qantra.tm.domain.services; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import zw.qantra.tm.domain.dtos.AuthResponse; +import zw.qantra.tm.domain.dtos.LoginRequest; +import zw.qantra.tm.domain.dtos.RegisterRequest; +import zw.qantra.tm.domain.models.User; +import zw.qantra.tm.domain.repositories.UserRepository; +import zw.qantra.tm.utils.JwtUtils; + +@Service +@RequiredArgsConstructor +public class UserService implements UserDetailsService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtils jwtUtils; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); + } + + public AuthResponse register(RegisterRequest request) { + if (userRepository.existsByUsername(request.getUsername())) { + throw new RuntimeException("Username already exists"); + } + + if (userRepository.existsByEmail(request.getEmail())) { + throw new RuntimeException("Email already exists"); + } + + User user = new User(); + user.setUsername(request.getUsername()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setEmail(request.getEmail()); + user.setFirstName(request.getFirstName()); + user.setLastName(request.getLastName()); + user.setPhone(request.getPhone()); + + User savedUser = userRepository.save(user); + String token = jwtUtils.generateToken(savedUser); + + return new AuthResponse(token, "Bearer", savedUser.getUsername(), + savedUser.getEmail(), savedUser.getPhone(), savedUser.getFirstName(), savedUser.getLastName()); + } + + public AuthResponse login(LoginRequest request) { + User user = userRepository.findByUsername(request.getUsername()) + .orElseThrow(() -> new RuntimeException("Invalid username or password")); + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new RuntimeException("Invalid username or password"); + } + + String token = jwtUtils.generateToken(user); + + return new AuthResponse(token, "Bearer", user.getUsername(), + user.getEmail(), user.getPhone(), user.getFirstName(), user.getLastName()); + } +} diff --git a/src/main/java/zw/qantra/tm/utils/JwtUtils.java b/src/main/java/zw/qantra/tm/utils/JwtUtils.java new file mode 100644 index 0000000..0edf53b --- /dev/null +++ b/src/main/java/zw/qantra/tm/utils/JwtUtils.java @@ -0,0 +1,72 @@ +package zw.qantra.tm.utils; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Component +public class JwtUtils { + + @Value("${jwt.secret:defaultSecretKey123456789012345678901234567890}") + private String secret; + + @Value("${jwt.expiration:86400000}") + private Long expiration; + + private SecretKey getSigningKey() { + return Keys.hmacShaKeyFor(secret.getBytes()); + } + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private Boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + public String generateToken(UserDetails userDetails) { + Map claims = new HashMap<>(); + return createToken(claims, userDetails.getUsername()); + } + + private String createToken(Map claims, String subject) { + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public Boolean validateToken(String token, UserDetails userDetails) { + final String username = extractUsername(token); + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2fb4c30..f196340 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -41,3 +41,7 @@ erpnext.income.account=5111 - Sales Income - QPAY erpnext.cost.center=Main - QPAY erpnext.tax.account=VAT - QPAY erpnext.tax.rate=15.0 + +# JWT Configuration +jwt.secret=your-super-secret-jwt-key-here-make-it-very-long-and-secure-in-production +jwt.expiration=86400000