diff --git a/pom.xml b/pom.xml index ec5a460..e3b8e68 100644 --- a/pom.xml +++ b/pom.xml @@ -107,6 +107,22 @@ spring-security-test test + + io.jsonwebtoken + jjwt-api + 0.13.0 + + io.jsonwebtoken + jjwt-impl + 0.13.0 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.13.0 + runtime + diff --git a/src/main/java/com/soupulsar/modulith/auth/application/config/AuthUseCaseConfig.java b/src/main/java/com/soupulsar/modulith/auth/application/config/AuthUseCaseConfig.java index 743a1ad..6eac44b 100644 --- a/src/main/java/com/soupulsar/modulith/auth/application/config/AuthUseCaseConfig.java +++ b/src/main/java/com/soupulsar/modulith/auth/application/config/AuthUseCaseConfig.java @@ -1,5 +1,6 @@ package com.soupulsar.modulith.auth.application.config; +import com.soupulsar.modulith.auth.application.security.JwtService; import com.soupulsar.modulith.auth.application.security.PasswordHasher; import com.soupulsar.modulith.auth.application.usecase.AuthenticateUserUseCase; import com.soupulsar.modulith.auth.application.usecase.RegisterUserUseCase; @@ -11,8 +12,8 @@ public class AuthUseCaseConfig { @Bean - public AuthenticateUserUseCase authenticateUserUseCase(UserRepository userRepository, PasswordHasher passwordHasher) { - return new AuthenticateUserUseCase(userRepository, passwordHasher); + public AuthenticateUserUseCase authenticateUserUseCase(UserRepository userRepository, PasswordHasher passwordHasher, JwtService jwtService) { + return new AuthenticateUserUseCase(userRepository, passwordHasher, jwtService); } @Bean diff --git a/src/main/java/com/soupulsar/modulith/auth/application/security/JwtService.java b/src/main/java/com/soupulsar/modulith/auth/application/security/JwtService.java new file mode 100644 index 0000000..478f5b7 --- /dev/null +++ b/src/main/java/com/soupulsar/modulith/auth/application/security/JwtService.java @@ -0,0 +1,80 @@ +package com.soupulsar.modulith.auth.application.security; + +import com.soupulsar.modulith.auth.domain.model.User; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.function.Function; + +@Service +public class JwtService { + + private final String secret; + private SecretKey secretKey; + private final Long expirationMs; + + public JwtService(@Value("${spring.security.jwt.secret}") String secret, + @Value("${spring.security.jwt.expiration}") Long expirationMs) { + this.secret = secret; + this.expirationMs = expirationMs; + } + + @PostConstruct + public void init() { + if (secret == null || secret.isBlank()) { + throw new IllegalArgumentException("JWT Secret não configurada!"); + } + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + + public String generateToken(User user) { + + Date issueDate = new Date(); + Date expiryDate = new Date(issueDate.getTime() + expirationMs); + + return Jwts.builder() + .subject(user.getEmail()) + .claim("userId", user.getUserId().toString()) + .claim("role", user.getRole()) + .claim("status", user.getStatus()) + .issuer("SouPulsar-AuthService") + .audience().add("SouPulsar-API").and() + .issuedAt(issueDate) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + + public boolean isTokenValid(String token, String username) { + final String extractedUsername = extractClaim(token, Claims::getSubject); + return (extractedUsername.equals(username) && !isTokenExpired(token)); + } + + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration).before(new Date()); + } + +} diff --git a/src/main/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCase.java b/src/main/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCase.java index 5fb4101..927b489 100644 --- a/src/main/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCase.java +++ b/src/main/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCase.java @@ -1,18 +1,19 @@ package com.soupulsar.modulith.auth.application.usecase; import com.soupulsar.modulith.auth.application.dto.AuthUserRequest; +import com.soupulsar.modulith.auth.application.security.JwtService; import com.soupulsar.modulith.auth.application.security.PasswordHasher; import com.soupulsar.modulith.auth.domain.model.User; import com.soupulsar.modulith.auth.domain.model.enums.UserStatus; import com.soupulsar.modulith.auth.domain.repository.UserRepository; import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; @RequiredArgsConstructor public class AuthenticateUserUseCase { private final UserRepository userRepository; private final PasswordHasher passwordHasher; + private final JwtService jwtService; public String execute(AuthUserRequest request) { @@ -27,8 +28,7 @@ public String execute(AuthUserRequest request) { throw new IllegalArgumentException("Invalid email or password"); } - return "JWT-TOKEN"; // Placeholder for actual JWT generation logic + return jwtService.generateToken(user); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/entity/UserEntity.java b/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/entity/UserEntity.java index 4531628..53414bc 100644 --- a/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/entity/UserEntity.java +++ b/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/entity/UserEntity.java @@ -24,7 +24,7 @@ public class UserEntity { @Column(nullable = false) private String name; - @Column(nullable = false) + @Column(nullable = false, unique = true) private String cpf; @Column(nullable = false) @@ -37,8 +37,10 @@ public class UserEntity { private String telephone; @Column(nullable = false) + @Enumerated(EnumType.STRING) private UserRole role; @Column(nullable = false) + @Enumerated(EnumType.STRING) private UserStatus status; } \ No newline at end of file diff --git a/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/mapper/UserMapper.java b/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/mapper/UserMapper.java index 277311c..6f41663 100644 --- a/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/mapper/UserMapper.java +++ b/src/main/java/com/soupulsar/modulith/auth/infrastructure/persistence/mapper/UserMapper.java @@ -10,22 +10,27 @@ public class UserMapper { public static UserEntity toEntity(User user){ UserEntity entity = new UserEntity(); + entity.setName(user.getName()); + entity.setCpf(user.getCpf()); + entity.setTelephone(user.getTelephone()); entity.setUserId(user.getUserId()); entity.setEmail(user.getEmail()); entity.setPassword(user.getPasswordHash()); + entity.setStatus(user.getStatus()); entity.setRole(user.getRole()); return entity; } public static User toModel(UserEntity entity){ - return User.restore(entity.getUserId(), + return User.restore( + entity.getUserId(), entity.getName(), + entity.getCpf(), + entity.getTelephone(), entity.getEmail(), entity.getPassword(), - entity.getTelephone(), - entity.getCpf(), entity.getRole(), - entity.getStatus()); + entity.getStatus() + ); } - } \ No newline at end of file diff --git a/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/BCryptPasswordHasher.java b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/BCryptPasswordHasher.java index 4e35da0..3a366a0 100644 --- a/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/BCryptPasswordHasher.java +++ b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/BCryptPasswordHasher.java @@ -16,6 +16,6 @@ public String hash(String password) { @Override public boolean matches(String rawPassword, String hashedPassword) { - return passwordEncoder.matches(hashedPassword, rawPassword); + return passwordEncoder.matches(rawPassword, hashedPassword); } } \ No newline at end of file diff --git a/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/CustomUserDetailsService.java b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/CustomUserDetailsService.java new file mode 100644 index 0000000..237b721 --- /dev/null +++ b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/CustomUserDetailsService.java @@ -0,0 +1,30 @@ +package com.soupulsar.modulith.auth.infrastructure.security; + +import com.soupulsar.modulith.auth.domain.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +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; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + + var user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + return User.builder() + .username(user.getEmail()) + .password(user.getPasswordHash()) + .roles(user.getRole().name()) + .build(); + } +} diff --git a/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/JwtAuthenticationFilter.java b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..db61031 --- /dev/null +++ b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/JwtAuthenticationFilter.java @@ -0,0 +1,54 @@ +package com.soupulsar.modulith.auth.infrastructure.security; + +import com.soupulsar.modulith.auth.application.security.JwtService; +import io.jsonwebtoken.Claims; +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.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String jwt = authHeader.substring(7); + String username = jwtService.extractClaim(jwt, Claims::getSubject); + + if(username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + var userDetails = userDetailsService.loadUserByUsername(username); + + if(jwtService.isTokenValid(jwt, userDetails.getUsername())) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + filterChain.doFilter(request, response); + + + } +} diff --git a/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/SecurityConfig.java b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/SecurityConfig.java new file mode 100644 index 0000000..f5ec906 --- /dev/null +++ b/src/main/java/com/soupulsar/modulith/auth/infrastructure/security/SecurityConfig.java @@ -0,0 +1,55 @@ +package com.soupulsar.modulith.auth.infrastructure.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +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.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; + +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public AuthenticationProvider authenticationProvider(CustomUserDetailsService userDetailsService) { + DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(userDetailsService); + daoAuthenticationProvider.setPasswordEncoder(passwordEncoder()); + return daoAuthenticationProvider; + } + +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 257be11..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=SouPulsar Modulith diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..e68634b --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,8 @@ +spring: + application: + name: SouPulsar Modulith + + security: + jwt: + secret: ${JWT_SECRET:jwt-super-secret-key-place-holder} + expiration: ${JWT_EXPIRATION:3600000} \ No newline at end of file diff --git a/src/test/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCaseTest.java b/src/test/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCaseTest.java index 830b736..fdb016b 100644 --- a/src/test/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCaseTest.java +++ b/src/test/java/com/soupulsar/modulith/auth/application/usecase/AuthenticateUserUseCaseTest.java @@ -1,6 +1,7 @@ package com.soupulsar.modulith.auth.application.usecase; import com.soupulsar.modulith.auth.application.dto.AuthUserRequest; +import com.soupulsar.modulith.auth.application.security.JwtService; import com.soupulsar.modulith.auth.application.security.PasswordHasher; import com.soupulsar.modulith.auth.domain.model.User; import com.soupulsar.modulith.auth.domain.model.enums.UserRole; @@ -20,12 +21,14 @@ class AuthenticateUserUseCaseTest { private UserRepository userRepository; private PasswordHasher passwordHasher; private AuthenticateUserUseCase useCase; + private JwtService jwtService; @BeforeEach void setUp() { userRepository = mock(UserRepository.class); passwordHasher = mock(PasswordHasher.class); - useCase = new AuthenticateUserUseCase(userRepository, passwordHasher); + jwtService = mock(JwtService.class); + useCase = new AuthenticateUserUseCase(userRepository, passwordHasher, jwtService); } @Test @@ -38,10 +41,12 @@ void shouldAuthenticateActiveUserWithCorrectPassword() { when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(user)); when(passwordHasher.matches(request.password(), user.getPasswordHash())).thenReturn(true); + when(jwtService.generateToken(user)).thenReturn("JWT-TOKEN"); String token = useCase.execute(request); assertEquals("JWT-TOKEN", token); + verify(jwtService).generateToken(user); } @Test @@ -56,6 +61,7 @@ void shouldThrowWhenUserIsInactive() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> useCase.execute(request)); assertEquals("User is not active", ex.getMessage()); + verify(jwtService, never()).generateToken(user); } @Test @@ -71,6 +77,7 @@ void shouldThrowWhenPasswordIsInvalid() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> useCase.execute(request)); assertEquals("Invalid email or password", ex.getMessage()); + verify(jwtService, never()).generateToken(user); } @Test @@ -81,5 +88,6 @@ void shouldThrowWhenUserNotFound() { IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> useCase.execute(request)); assertEquals("Invalid email or password", ex.getMessage()); + verify(jwtService, never()).generateToken(any()); } -} +} \ No newline at end of file