diff --git a/pom.xml b/pom.xml index 1ab2d6a..91cc172 100644 --- a/pom.xml +++ b/pom.xml @@ -87,6 +87,25 @@ spring-security-test test + + io.jsonwebtoken + jjwt-api + 0.12.7 + + + io.jsonwebtoken + jjwt-impl + 0.12.7 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.7 + runtime + + + junit @@ -138,6 +157,7 @@ org.springframework.boot spring-boot-starter-actuator + ch.qos.logback @@ -149,11 +169,13 @@ wiremock-spring-boot 3.10.0 + org.springframework.boot spring-boot-starter-mail + io.github.cdimascio diff --git a/src/main/java/org/pkwmtt/repository/UserRepository.java b/src/main/java/org/pkwmtt/repository/UserRepository.java index 71ccd75..1e53f12 100644 --- a/src/main/java/org/pkwmtt/repository/UserRepository.java +++ b/src/main/java/org/pkwmtt/repository/UserRepository.java @@ -2,6 +2,8 @@ import org.pkwmtt.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); } \ No newline at end of file diff --git a/src/main/java/org/pkwmtt/security/auth/AuthController.java b/src/main/java/org/pkwmtt/security/auth/AuthController.java new file mode 100644 index 0000000..f86dd7a --- /dev/null +++ b/src/main/java/org/pkwmtt/security/auth/AuthController.java @@ -0,0 +1,24 @@ +package org.pkwmtt.security.auth; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.security.auth.dto.UserRequestDTO; +import org.pkwmtt.security.token.JwtServiceImpl; +import org.pkwmtt.security.token.dto.UserDTO; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/pkwmtt/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthService authService; + private final JwtServiceImpl jwtServiceImpl; + + @PostMapping("/authenticate") + public String authenticate(@RequestBody UserRequestDTO requestUser) { + UserDTO user = authService.authenticateUser(requestUser.getEmail()); + return jwtServiceImpl.generateToken(user); + } +} diff --git a/src/main/java/org/pkwmtt/security/auth/AuthService.java b/src/main/java/org/pkwmtt/security/auth/AuthService.java new file mode 100644 index 0000000..3fc3cde --- /dev/null +++ b/src/main/java/org/pkwmtt/security/auth/AuthService.java @@ -0,0 +1,7 @@ +package org.pkwmtt.security.auth; + +import org.pkwmtt.security.token.dto.UserDTO; + +public interface AuthService { + UserDTO authenticateUser(String email); +} diff --git a/src/main/java/org/pkwmtt/security/auth/AuthServiceImpl.java b/src/main/java/org/pkwmtt/security/auth/AuthServiceImpl.java new file mode 100644 index 0000000..a878b52 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/auth/AuthServiceImpl.java @@ -0,0 +1,30 @@ +package org.pkwmtt.security.auth; + +import lombok.RequiredArgsConstructor; +import org.pkwmtt.security.auth.dto.UserRequestDTO; +import org.pkwmtt.security.token.dto.UserDTO; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthServiceImpl implements AuthService { + private final AuthenticationManager authenticationManager; + + @Override + public UserDTO authenticateUser(String email) { + Authentication authentication = authenticationManager.authenticate(new UsernamePasswordAuthenticationToken( + email, + null + )); + + if (!authentication.isAuthenticated()) { + throw new UsernameNotFoundException("Invalid credentials"); + } + + return (UserDTO) authentication.getPrincipal(); + } +} diff --git a/src/main/java/org/pkwmtt/security/auth/dto/UserRequestDTO.java b/src/main/java/org/pkwmtt/security/auth/dto/UserRequestDTO.java new file mode 100644 index 0000000..f30ed0e --- /dev/null +++ b/src/main/java/org/pkwmtt/security/auth/dto/UserRequestDTO.java @@ -0,0 +1,9 @@ +package org.pkwmtt.security.auth.dto; + +import lombok.Data; + +@Data +public class UserRequestDTO { + private String otp_code; + private String email; +} diff --git a/src/main/java/org/pkwmtt/security/auth/provider/OTPAuthenticationProvider.java b/src/main/java/org/pkwmtt/security/auth/provider/OTPAuthenticationProvider.java new file mode 100644 index 0000000..4baf894 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/auth/provider/OTPAuthenticationProvider.java @@ -0,0 +1,61 @@ +package org.pkwmtt.security.auth.provider; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.entity.User; +import org.pkwmtt.repository.UserRepository; +import org.pkwmtt.security.token.dto.UserDTO; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Stream; + +@Component +@RequiredArgsConstructor +public class OTPAuthenticationProvider implements AuthenticationProvider { + private final UserRepository userRepository; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String email = authentication.getName(); + + // Fetch user from DB + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new BadCredentialsException("User not found")); + + // Wrap role in a list to support multiple roles in the future + List authorities = Stream.of(user.getRole()) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role.name())) + .toList(); + + // Validate critical user fields + if(!isValidForAuthentication(user)){ + throw new BadCredentialsException( + "Invalid User Credentials. Please contact the administrator." + ); + } + + UserDTO userMapped = new UserDTO(user); + return new UsernamePasswordAuthenticationToken(userMapped, null, authorities); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } + + /** + * Validates user data before authentication. + * Returns true if user has email, role, group, and is active. + */ + private boolean isValidForAuthentication(User user) { + return user.getEmail() != null && + user.getRole() != null && + user.getGeneralGroup() != null && + user.isActive(); + } +} diff --git a/src/main/java/org/pkwmtt/security/config/SpringSecurity.java b/src/main/java/org/pkwmtt/security/config/SpringSecurity.java index c4dc591..37aea49 100644 --- a/src/main/java/org/pkwmtt/security/config/SpringSecurity.java +++ b/src/main/java/org/pkwmtt/security/config/SpringSecurity.java @@ -1,21 +1,29 @@ package org.pkwmtt.security.config; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.pkwmtt.security.auth.provider.OTPAuthenticationProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; 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.web.SecurityFilterChain; +import java.util.List; + import static org.springframework.security.config.Customizer.withDefaults; import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; //@EnableWebSecurity @Slf4j @Configuration +@RequiredArgsConstructor public class SpringSecurity { + private final OTPAuthenticationProvider otpAuthenticationProvider; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { log.info("Configuring Security Filter Chain..."); @@ -30,4 +38,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { log.info("Configuring Success..."); return http.build(); } + + @Bean + public AuthenticationManager authenticationManager() { + return new ProviderManager(List.of(otpAuthenticationProvider)); + } } diff --git a/src/main/java/org/pkwmtt/security/token/JwtService.java b/src/main/java/org/pkwmtt/security/token/JwtService.java new file mode 100644 index 0000000..e248f86 --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/JwtService.java @@ -0,0 +1,9 @@ +package org.pkwmtt.security.token; + +import org.pkwmtt.security.token.dto.UserDTO; + +public interface JwtService { + String generateToken(UserDTO user); + Boolean validateToken(String token); + String getUserIdFromToken(String token); +} diff --git a/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java b/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java new file mode 100644 index 0000000..f94079e --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/JwtServiceImpl.java @@ -0,0 +1,81 @@ +package org.pkwmtt.security.token; + +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.pkwmtt.security.token.dto.UserDTO; +import org.pkwmtt.security.token.utils.JwtUtils; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; + +@Service +@RequiredArgsConstructor +public class JwtServiceImpl implements JwtService { + private final JwtUtils jwtUtils; + + + /** + * Generates a JWT token for a given user. + * The token contains user's email, group, and role as claims, + * and is signed with a secret key. + * + * @param user - required user data to include in token claims + * @return signed JWT token as a String + */ + @Override + public String generateToken(UserDTO user) { + return Jwts.builder() + .subject(user.getEmail()) + .claim("group", user.getGroup()) + .claim("role", user.getRole()) + .issuedAt(new Date()) + .expiration((new Date(System.currentTimeMillis() + jwtUtils.getExpirationMs()))) + .signWith(decodeSecretKey()) + .compact(); + } + + + /** + * Decode a secret key for signing JWT. + * The key is decoded from Base64 stored in JwtUtils configuration. + * + * @return secret key for JWT signing + */ + private SecretKey decodeSecretKey(){ + byte[] decodedKey = Base64.getDecoder().decode(jwtUtils.getSecret()); + return Keys.hmacShaKeyFor(decodedKey); + } + + /** + * Validate a JWT token. + * Attempts to parse the token; if parsing fails, the token is considered invalid. + * + * @param token JWT token string to validate + * @return true if the token is valid, false otherwise + */ + @Override + public Boolean validateToken(String token) { + try { + // TODO: add logic to validate the token + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + /** + * Extracts the user identifier (email) from a JWT token. + * + * @param token JWT token to extract user from + * @return user email from token + */ + @Override + public String getUserIdFromToken(String token) { + // TODO: implement token parsing to extract subject/email + return ""; + } +} diff --git a/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java b/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java new file mode 100644 index 0000000..b193edd --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/dto/UserDTO.java @@ -0,0 +1,23 @@ +package org.pkwmtt.security.token.dto; + +import lombok.Data; +import org.pkwmtt.entity.GeneralGroup; +import org.pkwmtt.entity.User; +import org.pkwmtt.enums.Role; + +import java.util.Optional; + +@Data +public class UserDTO { + private String email; + private String group; + private Role role; + + public UserDTO(User user){ + this.email = user.getEmail(); + this.role = user.getRole(); + this.group = Optional.ofNullable(user.getGeneralGroup()) + .map(GeneralGroup::getName) + .orElse(null); + } +} diff --git a/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java b/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java new file mode 100644 index 0000000..21ee1bb --- /dev/null +++ b/src/main/java/org/pkwmtt/security/token/utils/JwtUtils.java @@ -0,0 +1,20 @@ +package org.pkwmtt.security.token.utils; + +import lombok.Getter; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Getter +@Component +public class JwtUtils { + // Secret key used for signing JWTs. If the environment variable JWT_SECRET_KEY + // is not set, a default value "TEST_SECRET" is used. This allows the application + // to start without a real secret, e.g., for local development or tests. + private final String secret; + private final long expirationMs = 1000L * 60 * 60 * 24 * 30 * 6; + + public JwtUtils(Environment environment) { + // Get the secret key from environment variables, or fallback to "TEST_SECRET" + this.secret = environment.getProperty("JWT_SECRET_KEY", "TEST_SECRET"); + } +} diff --git a/src/test/java/org/pkwmtt/jwt/JwtSecretMakerTest.java b/src/test/java/org/pkwmtt/jwt/JwtSecretMakerTest.java new file mode 100644 index 0000000..ed43d5d --- /dev/null +++ b/src/test/java/org/pkwmtt/jwt/JwtSecretMakerTest.java @@ -0,0 +1,21 @@ +package org.pkwmtt.jwt; + +import io.jsonwebtoken.Jwts; +import jakarta.xml.bind.DatatypeConverter; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class JwtSecretMakerTest { + + @Test + public void generateSecretKey(){ + SecretKey key = Jwts.SIG.HS512.key().build(); + String encodedKey = DatatypeConverter.printHexBinary(key.getEncoded()); + System.out.printf("\nKey = [%s]\n", encodedKey); + String base64Secret = Base64.getEncoder().encodeToString(encodedKey.getBytes(StandardCharsets.UTF_8)); + System.out.println(base64Secret); + } +}