diff --git a/gateway/pom.xml b/gateway/pom.xml index 826928d..c3e5e21 100644 --- a/gateway/pom.xml +++ b/gateway/pom.xml @@ -57,6 +57,42 @@ spring-boot-starter-test test + + com.auth0 + java-jwt + 4.4.0 + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.security + spring-security-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.postgresql + postgresql + runtime + 42.6.0 + + + org.projectlombok + lombok + true + diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/RestClientConfig.java b/gateway/src/main/java/com/lucasmoraist/gateway/config/rest/RestClientConfig.java similarity index 87% rename from gateway/src/main/java/com/lucasmoraist/gateway/RestClientConfig.java rename to gateway/src/main/java/com/lucasmoraist/gateway/config/rest/RestClientConfig.java index 1c88903..fcd9f0e 100644 --- a/gateway/src/main/java/com/lucasmoraist/gateway/RestClientConfig.java +++ b/gateway/src/main/java/com/lucasmoraist/gateway/config/rest/RestClientConfig.java @@ -1,4 +1,4 @@ -package com.lucasmoraist.gateway; +package com.lucasmoraist.gateway.config.rest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/config/security/SecurityConfig.java b/gateway/src/main/java/com/lucasmoraist/gateway/config/security/SecurityConfig.java new file mode 100644 index 0000000..fd9d4d0 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/config/security/SecurityConfig.java @@ -0,0 +1,48 @@ +package com.lucasmoraist.gateway.config.security; + +import org.springframework.beans.factory.annotation.Autowired; +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.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.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 +@EnableWebSecurity +public class SecurityConfig { + + @Autowired + SecurityFilter securityFilter; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers(HttpMethod.POST, "/auth/login").permitAll() + .requestMatchers(HttpMethod.POST, "/auth/register").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(securityFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/config/security/SecurityFilter.java b/gateway/src/main/java/com/lucasmoraist/gateway/config/security/SecurityFilter.java new file mode 100644 index 0000000..d4022e6 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/config/security/SecurityFilter.java @@ -0,0 +1,47 @@ +package com.lucasmoraist.gateway.config.security; + +import com.lucasmoraist.gateway.domain.entity.User; +import com.lucasmoraist.gateway.repository.UserRepository; +import com.lucasmoraist.gateway.service.TokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@Component +public class SecurityFilter extends OncePerRequestFilter { + + @Autowired + private TokenService tokenService; + @Autowired + private UserRepository userRepository; + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException { + var token = this.recoverToken(req); + var login = tokenService.validateToken(token); + if(login != null){ + User user = userRepository.findByEmail(login).orElseThrow(() -> new RuntimeException("User Not Found")); + var authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")); + var authentication = new UsernamePasswordAuthenticationToken(user, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(req, res); + } + + private String recoverToken(HttpServletRequest request){ + var authHeader = request.getHeader("Authorization"); + if(authHeader == null) return null; + return authHeader.replace("Bearer ", ""); + } + +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/controller/UserController.java b/gateway/src/main/java/com/lucasmoraist/gateway/controller/UserController.java new file mode 100644 index 0000000..d47de99 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/controller/UserController.java @@ -0,0 +1,38 @@ +package com.lucasmoraist.gateway.controller; + +import com.lucasmoraist.gateway.domain.dto.TokenDTO; +import com.lucasmoraist.gateway.domain.model.LoginRequest; +import com.lucasmoraist.gateway.domain.model.RegisterRequest; +import com.lucasmoraist.gateway.service.UserService; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +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; + +@Slf4j +@RestController +@RequestMapping("/auth") +public class UserController { + + @Autowired + private UserService service; + + @PostMapping("register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) throws Exception { + this.service.register(request); + log.info("Registering"); + return ResponseEntity.ok().build(); + } + + @PostMapping("login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) throws Exception { + var response = this.service.login(request); + log.info("Logging in"); + return ResponseEntity.ok().body(response); + } + +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/domain/dto/TokenDTO.java b/gateway/src/main/java/com/lucasmoraist/gateway/domain/dto/TokenDTO.java new file mode 100644 index 0000000..0bd9285 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/domain/dto/TokenDTO.java @@ -0,0 +1,4 @@ +package com.lucasmoraist.gateway.domain.dto; + +public record TokenDTO(String token) { +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/domain/entity/User.java b/gateway/src/main/java/com/lucasmoraist/gateway/domain/entity/User.java new file mode 100644 index 0000000..01526f8 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/domain/entity/User.java @@ -0,0 +1,42 @@ +package com.lucasmoraist.gateway.domain.entity; + +import com.lucasmoraist.gateway.domain.model.LoginRequest; +import com.lucasmoraist.gateway.domain.model.RegisterRequest; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Entity(name = "t_user") +@Table(name = "t_user") +public class User { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 80) + private String name; + + @Column(nullable = false, length = 255, unique = true) + private String email; + + @Column(nullable = false, length = 20) + private String password; + + public User(RegisterRequest request) { + this.name = request.getName(); + this.email = request.getEmail(); + this.password = request.getPassword(); + } + + public User(LoginRequest request) { + this.email = request.getEmail(); + this.password = request.getPassword(); + } + +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/domain/model/LoginRequest.java b/gateway/src/main/java/com/lucasmoraist/gateway/domain/model/LoginRequest.java new file mode 100644 index 0000000..d4b4669 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/domain/model/LoginRequest.java @@ -0,0 +1,21 @@ +package com.lucasmoraist.gateway.domain.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + + @NotBlank + @Email + private String email; + + @NotBlank + private String password; + +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/domain/model/RegisterRequest.java b/gateway/src/main/java/com/lucasmoraist/gateway/domain/model/RegisterRequest.java new file mode 100644 index 0000000..4feec75 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/domain/model/RegisterRequest.java @@ -0,0 +1,30 @@ +package com.lucasmoraist.gateway.domain.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RegisterRequest { + + @Size(min = 3, max = 80) + private String name; + + @NotBlank + @Email(message = "Invalid email") + @Size(max = 255) + private String email; + + @NotBlank + @Size(min = 8, max = 20) + @Pattern(regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).*$", + message = "Password must contain at least one uppercase letter, one lowercase letter and one digit") + private String password; + +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/repository/UserRepository.java b/gateway/src/main/java/com/lucasmoraist/gateway/repository/UserRepository.java new file mode 100644 index 0000000..46d537f --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/repository/UserRepository.java @@ -0,0 +1,10 @@ +package com.lucasmoraist.gateway.repository; + +import com.lucasmoraist.gateway.domain.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/service/TokenService.java b/gateway/src/main/java/com/lucasmoraist/gateway/service/TokenService.java new file mode 100644 index 0000000..f9245a4 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/service/TokenService.java @@ -0,0 +1,8 @@ +package com.lucasmoraist.gateway.service; + +import com.lucasmoraist.gateway.domain.entity.User; + +public interface TokenService { + String generateToken(User user); + String validateToken(String token); +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/service/UserDetailsService.java b/gateway/src/main/java/com/lucasmoraist/gateway/service/UserDetailsService.java new file mode 100644 index 0000000..550de21 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/service/UserDetailsService.java @@ -0,0 +1,8 @@ +package com.lucasmoraist.gateway.service; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +public interface UserDetailsService { + UserDetails loadUserByUsername(String username) throws UsernameNotFoundException; +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/service/UserService.java b/gateway/src/main/java/com/lucasmoraist/gateway/service/UserService.java new file mode 100644 index 0000000..06a5e39 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/service/UserService.java @@ -0,0 +1,10 @@ +package com.lucasmoraist.gateway.service; + +import com.lucasmoraist.gateway.domain.dto.TokenDTO; +import com.lucasmoraist.gateway.domain.model.LoginRequest; +import com.lucasmoraist.gateway.domain.model.RegisterRequest; + +public interface UserService { + void register(RegisterRequest request) throws Exception; + TokenDTO login(LoginRequest request) throws Exception; +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/TokenServiceImpl.java b/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/TokenServiceImpl.java new file mode 100644 index 0000000..3862443 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/TokenServiceImpl.java @@ -0,0 +1,54 @@ +package com.lucasmoraist.gateway.service.impl; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTCreationException; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.lucasmoraist.gateway.domain.entity.User; +import com.lucasmoraist.gateway.service.TokenService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; + +@Service +public class TokenServiceImpl implements TokenService { + + @Value("${jwt.secret}") + private String secret; + + @Override + public String generateToken(User user) { + try { + Algorithm algorithm = Algorithm.HMAC256(secret); + + return JWT.create() + .withIssuer("task-list") + .withSubject(user.getEmail()) + .withExpiresAt(this.generateExpirationDate()) + .sign(algorithm); + } catch(JWTCreationException e){ + throw new RuntimeException("Error while authentication"); + } + } + + @Override + public String validateToken(String token) { + try { + Algorithm algorithm = Algorithm.HMAC256(secret); + return JWT.require(algorithm) + .withIssuer("task-list") + .build() + .verify(token) + .getSubject(); + } catch(JWTVerificationException e) { + return null; + } + } + + private Instant generateExpirationDate(){ + return LocalDateTime.now().plusHours(2).toInstant(ZoneOffset.of("-03:00")); + } +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/UserDetailsServiceImpl.java b/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/UserDetailsServiceImpl.java new file mode 100644 index 0000000..3772db3 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/UserDetailsServiceImpl.java @@ -0,0 +1,26 @@ +package com.lucasmoraist.gateway.service.impl; + +import com.lucasmoraist.gateway.domain.entity.User; +import com.lucasmoraist.gateway.repository.UserRepository; +import com.lucasmoraist.gateway.service.UserDetailsService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; + +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + @Autowired + private UserRepository repository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = this.repository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("User Not Found")); + + return new org.springframework.security.core.userdetails.User(user.getEmail(), user.getPassword(), new ArrayList<>()); + } +} diff --git a/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/UserServiceImpl.java b/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..e111571 --- /dev/null +++ b/gateway/src/main/java/com/lucasmoraist/gateway/service/impl/UserServiceImpl.java @@ -0,0 +1,64 @@ +package com.lucasmoraist.gateway.service.impl; + +import com.lucasmoraist.gateway.domain.dto.TokenDTO; +import com.lucasmoraist.gateway.domain.entity.User; +import com.lucasmoraist.gateway.domain.model.LoginRequest; +import com.lucasmoraist.gateway.domain.model.RegisterRequest; +import com.lucasmoraist.gateway.repository.UserRepository; +import com.lucasmoraist.gateway.service.TokenService; +import com.lucasmoraist.gateway.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository repository; + private final TokenService tokenService; + private final PasswordEncoder passwordEncoder; + + @Override + public void register(RegisterRequest request) throws Exception { + log.info("Attempting to register user with email: {}", request.getEmail()); + + Optional optionalUser = this.repository.findByEmail(request.getEmail()); + if (optionalUser.isPresent()) { + log.error("Registration failed: email already in use: {}", request.getEmail()); + throw new Exception("Email already in use"); + } + + User newUser = new User(request); + newUser.setPassword(this.passwordEncoder.encode(request.getPassword())); + + this.repository.save(newUser); + + log.info("Registration successful for email: {}", request.getEmail()); + } + + @Override + public TokenDTO login(LoginRequest request) throws Exception { + log.info("Attempting to authenticate user with email: {}", request.getEmail()); + + User user = this.repository.findByEmail(request.getEmail()) + .orElseThrow(() -> { + log.error("Authentication failed: email not found for email: {}", request.getEmail()); + return new Exception("Email not found"); + }); + + if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + log.error("Authentication failed: incorrect password for email: {}", request.getEmail()); + throw new Exception("Incorrect password"); + } + + String token = this.tokenService.generateToken(user); + + log.info("Authentication successful for email: {}", request.getEmail()); + return new TokenDTO(token); + } +} diff --git a/gateway/src/main/resources/application.properties b/gateway/src/main/resources/application.properties index 5471b43..8f0d68b 100644 --- a/gateway/src/main/resources/application.properties +++ b/gateway/src/main/resources/application.properties @@ -1,7 +1,15 @@ +spring.datasource.url=jdbc:postgresql://localhost:5434/db_user +spring.datasource.username=postgres +spring.datasource.password=password +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + spring.application.name=gateway server.port=8082 eureka.client.serviceUrl.defaultZone=http://localhost:8761/eureka/ spring.cloud.gateway.discovery.locator.enabled=true spring.cloud.gateway.discovery.locator.lower-case-service-id=true -spring.main.web-application-type=reactive \ No newline at end of file +spring.main.web-application-type=reactive +spring.main.allow-bean-definition-overriding=true + +jwt.secret=secret \ No newline at end of file