added jwt auth to service

This commit is contained in:
2025-08-28 23:50:54 +02:00
parent 67752d689d
commit 61f5503bdb
14 changed files with 594 additions and 0 deletions

93
JWT_AUTH_README.md Normal file
View File

@@ -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

25
pom.xml
View File

@@ -108,6 +108,31 @@
<artifactId>specification-arg-resolver</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.3</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.3</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
AuthResponse response = userService.register(request);
return ResponseEntity.ok(response);
}
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
AuthResponse response = userService.login(request);
return ResponseEntity.ok(response);
}
@GetMapping("/test")
public ResponseEntity<String> test() {
return ResponseEntity.ok("Authentication endpoints are working!");
}
}

View File

@@ -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<String> protectedEndpoint() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication.getName();
return ResponseEntity.ok("Hello " + username + "! This is a protected endpoint.");
}
@GetMapping("/public")
public ResponseEntity<String> publicEndpoint() {
return ResponseEntity.ok("This is a public endpoint - no authentication required!");
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<? extends GrantedAuthority> 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;
}
}

View File

@@ -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<User, Long> {
Optional<User> findByUsername(String username);
Optional<User> findByEmail(String email);
Optional<User> findByPhone(String phone);
boolean existsByUsername(String username);
boolean existsByEmail(String email);
}

View File

@@ -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());
}
}

View File

@@ -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> T extractClaim(String token, Function<Claims, T> 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<String, Object> claims = new HashMap<>();
return createToken(claims, userDetails.getUsername());
}
private String createToken(Map<String, Object> 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));
}
}

View File

@@ -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