diff --git a/docker-compose.yml b/docker-compose.yml index 7e669a1..71f3ee4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,6 @@ services: client: container_name: tasknote - profiles: - - "dev" image: node:20.17-bullseye-slim ports: - "5000:5000" @@ -20,8 +18,6 @@ services: java-api: container_name: java-api - profiles: - - "dev" depends_on: postgres: condition: service_started @@ -49,8 +45,6 @@ services: postgres: container_name: postgres - profiles: - - "dev" image: postgres:15.8-bookworm environment: POSTGRES_DB: tasknote @@ -59,7 +53,7 @@ services: POSTGRES_PASSWORD: default POSTGRES_PORT: 5432 volumes: - - "./localpgdata:/pgdata" + - "/pgdata" ports: - "5432:5432" healthcheck: diff --git a/java-api/pom.xml b/java-api/pom.xml index db437f2..4df81cb 100644 --- a/java-api/pom.xml +++ b/java-api/pom.xml @@ -102,6 +102,25 @@ springdoc-openapi-starter-webmvc-ui 2.6.0 + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/JavaApiApplication.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/JavaApiApplication.java index 7ef4353..86382b9 100644 --- a/java-api/src/main/java/br/com/tasknoteapp/java_api/JavaApiApplication.java +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/JavaApiApplication.java @@ -3,11 +3,16 @@ package br.com.tasknoteapp.java_api; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +/** Entrypoint of the Java API service application. */ @SpringBootApplication public class JavaApiApplication { - public static void main(String[] args) { - SpringApplication.run(JavaApiApplication.class, args); - } - + /** + * Main method of the application. + * + * @param args Additional arguments, if any. + */ + public static void main(String[] args) { + SpringApplication.run(JavaApiApplication.class, args); + } } diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/auth/JwtAuthenticationResponse.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/auth/JwtAuthenticationResponse.java new file mode 100644 index 0000000..8848cde --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/auth/JwtAuthenticationResponse.java @@ -0,0 +1,3 @@ +package br.com.tasknoteapp.java_api.auth; + +public record JwtAuthenticationResponse(String token) {} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/auth/LoginRequest.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/auth/LoginRequest.java new file mode 100644 index 0000000..b2351df --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/auth/LoginRequest.java @@ -0,0 +1,11 @@ +package br.com.tasknoteapp.java_api.auth; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotNull; + +/** This record represents a login request with user email and password. */ +@Schema(description = "Login request with user email and password.") +public record LoginRequest( + @Schema(description = "User email.") @Email @NotNull String email, + @Schema(description = "User password.") @NotNull String password) {} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SecurityConfig.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SecurityConfig.java index 9d14c11..c5a7f2d 100644 --- a/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SecurityConfig.java +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SecurityConfig.java @@ -1,22 +1,35 @@ package br.com.tasknoteapp.java_api.config; +import br.com.tasknoteapp.java_api.filter.JwtAuthenticationFilter; +import br.com.tasknoteapp.java_api.service.UserService; +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.Customizer; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; /** This class contains security configurations. */ @Configuration @EnableWebSecurity -@EnableMethodSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final UserService userService; + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + /** * Filters a request to add security checks and configurations. * @@ -27,18 +40,44 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.cors(Customizer.withDefaults()) - .csrf(custom -> custom.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) + .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests( - custom -> - custom + request -> + request + .requestMatchers("/auth/**") + .permitAll() .requestMatchers("/rest/**") .authenticated() .requestMatchers(HttpMethod.OPTIONS, "/**") .permitAll() .anyRequest() .permitAll()) + .sessionManagement( + manager -> manager.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .httpBasic(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable); + .formLogin(AbstractHttpConfigurer::disable) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userService.userDetailsService()); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) + throws Exception { + return config.getAuthenticationManager(); + } } diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SwaggerConfig.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SwaggerConfig.java index 4548eb2..753294e 100644 --- a/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SwaggerConfig.java +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/config/SwaggerConfig.java @@ -55,6 +55,6 @@ public class SwaggerConfig { openApi.addSecurityItem(new SecurityRequirement().addList("bearerAuth")); openApi.setComponents(components); - return new OpenAPI().info(info); + return openApi; } } diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/controller/AuthenticationController.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/controller/AuthenticationController.java new file mode 100644 index 0000000..d4adae7 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/controller/AuthenticationController.java @@ -0,0 +1,68 @@ +package br.com.tasknoteapp.java_api.controller; + +import br.com.tasknoteapp.java_api.auth.JwtAuthenticationResponse; +import br.com.tasknoteapp.java_api.auth.LoginRequest; +import br.com.tasknoteapp.java_api.exception.UserAlreadyExistsException; +import br.com.tasknoteapp.java_api.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/auth") +@Tag(name = "Authentication", description = "Authentication controller.") +@AllArgsConstructor +public class AuthenticationController { + + private final AuthService authService; + + /** + * Authenticate a user given his email and password. + * + * @param loginRequest User data containing email and password. + * @return OK if authenticated, 401 - Unauthorized otherwise + */ + @PostMapping(path = "/signin", consumes = "application/json", produces = "application/json") + @Operation( + summary = "SigIn an existing user", + description = "SigIn an existing user given his email and password", + responses = { + @ApiResponse(responseCode = "200", description = "User successfully logged in"), + @ApiResponse(responseCode = "400", description = "Wrong or missing information"), + }) + public JwtAuthenticationResponse signin(@RequestBody @Valid LoginRequest loginRequest) { + String token = authService.signin(loginRequest); + return new JwtAuthenticationResponse(token); + } + + /** + * Signup a new user. + * + * @param loginRequest User data with email and password. + * @return JwtAuthenticationResponse containing user token + * @throws UserAlreadyExistsException + */ + @PutMapping(path = "/signup", consumes = "application/json", produces = "application/json") + @Operation( + summary = "Signup a new user", + description = "Signup a new user given his email and password", + responses = { + @ApiResponse(responseCode = "201", description = "User successfully created and saved"), + @ApiResponse(responseCode = "400", description = "Wrong or missing information"), + @ApiResponse(responseCode = "409", description = "User already exists") + }) + public ResponseEntity signup( + @RequestBody @Valid LoginRequest loginRequest) { + String token = authService.create(loginRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(new JwtAuthenticationResponse(token)); + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/controller/SomeController.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/controller/SomeController.java new file mode 100644 index 0000000..4294e91 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/controller/SomeController.java @@ -0,0 +1,15 @@ +package br.com.tasknoteapp.java_api.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/rest/some") +public class SomeController { + + @GetMapping + public String some() { + return "Some"; + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/entity/UserEntity.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/entity/UserEntity.java new file mode 100644 index 0000000..1c04f14 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/entity/UserEntity.java @@ -0,0 +1,71 @@ +package br.com.tasknoteapp.java_api.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import lombok.Data; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +/** This class represents a User in the database. */ +@Data +@Entity +@Table(name = "users") +public class UserEntity implements UserDetails { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private Boolean admin; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "inactivated_at", nullable = true) + private LocalDateTime inactivatedAt; + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getUsername() { + // email in our case + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/exception/UserAlreadyExistsException.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/exception/UserAlreadyExistsException.java new file mode 100644 index 0000000..cca7b1a --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/exception/UserAlreadyExistsException.java @@ -0,0 +1,13 @@ +package br.com.tasknoteapp.java_api.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.server.ResponseStatusException; + +@ResponseStatus(code = HttpStatus.CONFLICT) +public class UserAlreadyExistsException extends ResponseStatusException { + + public UserAlreadyExistsException() { + super(HttpStatus.CONFLICT, "User already exists"); + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/exception/UserNotFoundException.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/exception/UserNotFoundException.java new file mode 100644 index 0000000..8d61ddd --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/exception/UserNotFoundException.java @@ -0,0 +1,13 @@ +package br.com.tasknoteapp.java_api.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.server.ResponseStatusException; + +@ResponseStatus(code = HttpStatus.NOT_FOUND) +public class UserNotFoundException extends ResponseStatusException { + + public UserNotFoundException() { + super(HttpStatus.NOT_FOUND, "User not found"); + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/filter/JwtAuthenticationFilter.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..fd659fa --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/filter/JwtAuthenticationFilter.java @@ -0,0 +1,74 @@ +package br.com.tasknoteapp.java_api.filter; + +import br.com.tasknoteapp.java_api.service.JwtService; +import br.com.tasknoteapp.java_api.service.UserService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContext; +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; + +@Slf4j +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + @Autowired private UserService userService; + + @Autowired private JwtService jwtService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + final String authorizationHeader = request.getHeader("Authorization"); + + log.info("-1-authorizationHeader {}", authorizationHeader); + + if (Objects.isNull(authorizationHeader) || authorizationHeader.isBlank()) { + log.info("-2-authorizationHeader {}", authorizationHeader); + filterChain.doFilter(request, response); + return; + } + + log.info("-3-authorizationHeader {}", authorizationHeader); + + String jwtToken = authorizationHeader.substring(7); + String email = jwtService.getEmailFromToken(jwtToken); + + log.info("-4-email {}", email); + + if (!Objects.isNull(email) + && Objects.isNull(SecurityContextHolder.getContext().getAuthentication())) { + log.info("-5-email {}", email); + UserDetails user = userService.userDetailsService().loadUserByUsername(email); + + if (jwtService.validateTokenAndUser(jwtToken, user)) { + log.info("-6-jwtToken {}", jwtToken); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken( + user.getUsername(), user.getPassword(), user.getAuthorities()); + + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + context.setAuthentication(authToken); + SecurityContextHolder.setContext(context); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/repository/UserRepository.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/repository/UserRepository.java new file mode 100644 index 0000000..af80264 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/repository/UserRepository.java @@ -0,0 +1,11 @@ +package br.com.tasknoteapp.java_api.repository; + +import br.com.tasknoteapp.java_api.entity.UserEntity; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +/** This interface contains methods to access the user table in the database. */ +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/service/AuthService.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/AuthService.java new file mode 100644 index 0000000..91b4a53 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/AuthService.java @@ -0,0 +1,41 @@ +package br.com.tasknoteapp.java_api.service; + +import br.com.tasknoteapp.java_api.auth.LoginRequest; +import br.com.tasknoteapp.java_api.entity.UserEntity; +import java.util.Optional; +import org.springframework.security.core.userdetails.User; + +public interface AuthService { + + /** + * Create a new user in the app. + * + * @param login User details with email and password. + * @return Token + */ + public String create(LoginRequest login); + + /** + * Find a user by email in the database. + * + * @param email The user email. + * @return Optional of a UserEntity instance. + */ + public Optional findByEmail(String email); + + /** + * Load a user from the database given his email. + * + * @param email The user email. + * @return User with found record. + */ + public User loadUserByUsername(String email); + + /** + * SignIn a user given his email and password. + * + * @param login User details with email and password. + * @return Token + */ + public String signin(LoginRequest login); +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/service/JwtService.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/JwtService.java new file mode 100644 index 0000000..7246f78 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/JwtService.java @@ -0,0 +1,20 @@ +package br.com.tasknoteapp.java_api.service; + +import java.time.LocalDateTime; +import java.util.Map; +import org.springframework.security.core.userdetails.UserDetails; + +public interface JwtService { + + public String getEmailFromToken(String token); + + public LocalDateTime extractExpiration(String token); + + public String generateToken(String username); + + public String createToken(Map claims, String email); + + public boolean isTokenExpired(String token); + + public boolean validateTokenAndUser(String token, UserDetails user); +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/service/UserService.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/UserService.java new file mode 100644 index 0000000..1bb628b --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/UserService.java @@ -0,0 +1,7 @@ +package br.com.tasknoteapp.java_api.service; + +import org.springframework.security.core.userdetails.UserDetailsService; + +public interface UserService { + UserDetailsService userDetailsService(); +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/AuthServiceImpl.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/AuthServiceImpl.java new file mode 100644 index 0000000..a9b9f84 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/AuthServiceImpl.java @@ -0,0 +1,111 @@ +package br.com.tasknoteapp.java_api.service.impl; + +import br.com.tasknoteapp.java_api.auth.LoginRequest; +import br.com.tasknoteapp.java_api.entity.UserEntity; +import br.com.tasknoteapp.java_api.exception.UserAlreadyExistsException; +import br.com.tasknoteapp.java_api.exception.UserNotFoundException; +import br.com.tasknoteapp.java_api.repository.UserRepository; +import br.com.tasknoteapp.java_api.service.AuthService; +import br.com.tasknoteapp.java_api.service.JwtService; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Optional; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@AllArgsConstructor +class AuthServiceImpl implements AuthService { + + private final UserRepository userRepository; + + private final PasswordEncoder passwordEncoder; + + private final JwtService jwtService; + + private final AuthenticationManager authenticationManager; + + /** + * Create a new user in the app. + * + * @param login User details with email and password. + * @return Token + */ + @Override + public String create(LoginRequest login) { + log.info("Creating user! {}", login.email()); + + if (findByEmail(login.email()).isPresent()) { + throw new UserAlreadyExistsException(); + } + + UserEntity user = new UserEntity(); + user.setEmail(login.email()); + user.setPassword(passwordEncoder.encode(login.password())); + user.setAdmin(login.email().equals("ricardompcampos@gmail.com")); + user.setCreatedAt(LocalDateTime.now()); + userRepository.save(user); + + String token = jwtService.generateToken(user.getEmail()); + + log.info("User created! Token {}", token); + return token; + } + + /** + * Find a user by email in the database. + * + * @param email The user email. + * @return Optional of a UserEntity instance. + */ + @Override + public Optional findByEmail(String email) { + return userRepository.findByEmail(email); + } + + /** + * Load a user from the database given his email. + * + * @param email The user email. + * @return User with found record. + */ + @Override + public User loadUserByUsername(String email) { + Optional user = userRepository.findByEmail(email); + if (user.isEmpty()) { + throw new UserNotFoundException(); + } + + return new User(user.get().getEmail(), user.get().getPassword(), new ArrayList<>()); + } + + /** + * SignIn a user given his email and password. + * + * @param login User details with email and password. + * @return Token + */ + @Override + public String signin(LoginRequest login) { + log.info("Creating user! {}", login.email()); + + Optional user = findByEmail(login.email()); + if (user.isEmpty()) { + throw new UserNotFoundException(); + } + + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(login.email(), login.password())); + + String token = jwtService.generateToken(user.get().getEmail()); + + log.info("User authenticated! Token {}", token); + return token; + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/JwtServiceImpl.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/JwtServiceImpl.java new file mode 100644 index 0000000..acfc44d --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/JwtServiceImpl.java @@ -0,0 +1,70 @@ +package br.com.tasknoteapp.java_api.service.impl; + +import br.com.tasknoteapp.java_api.service.JwtService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import javax.crypto.SecretKey; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +@Service +public class JwtServiceImpl implements JwtService { + + private final long SECOND = 1000; + private final long MINUTE = SECOND * 60; + private final long EXPIRATION_TIME = MINUTE * 30; + private final SecretKey KEY = Jwts.SIG.HS256.key().build(); + + @Override + public String getEmailFromToken(String token) { + return extractClaim(token, Claims::getSubject); + } + + @Override + public LocalDateTime extractExpiration(String token) { + Date date = extractClaim(token, Claims::getExpiration); + return date.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDateTime(); + } + + @Override + public String generateToken(String username) { + Map claims = new HashMap<>(); + return createToken(claims, username); + } + + @Override + public String createToken(Map claims, String email) { + return Jwts.builder() + .issuer("Java-API") + .subject(email) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) + .signWith(KEY) + .compact(); + } + + @Override + public boolean isTokenExpired(String token) { + return extractExpiration(token).isBefore(LocalDateTime.now()); + } + + @Override + public boolean validateTokenAndUser(String token, UserDetails user) { + final String email = user.getUsername(); + return !isTokenExpired(token) && getEmailFromToken(token).equals(email); + } + + private T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + private Claims extractAllClaims(String token) { + return Jwts.parser().verifyWith(KEY).build().parseSignedClaims(token).getPayload(); + } +} diff --git a/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/UserServiceImpl.java b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/UserServiceImpl.java new file mode 100644 index 0000000..b485b22 --- /dev/null +++ b/java-api/src/main/java/br/com/tasknoteapp/java_api/service/impl/UserServiceImpl.java @@ -0,0 +1,32 @@ +package br.com.tasknoteapp.java_api.service.impl; + +import br.com.tasknoteapp.java_api.entity.UserEntity; +import br.com.tasknoteapp.java_api.repository.UserRepository; +import br.com.tasknoteapp.java_api.service.UserService; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + + @Override + public UserDetailsService userDetailsService() { + return new UserDetailsService() { + @Override + public UserDetails loadUserByUsername(String email) { + Optional user = userRepository.findByEmail(email); + if (user.isEmpty()) { + throw new RuntimeException("User not found: " + email); + } + + return user.get(); + } + }; + } +} diff --git a/java-api/src/main/resources/db/migration/V202209041850__schema_and_user_creation.sql b/java-api/src/main/resources/db/migration/V202209041850__schema_and_user_creation.sql new file mode 100644 index 0000000..eefed85 --- /dev/null +++ b/java-api/src/main/resources/db/migration/V202209041850__schema_and_user_creation.sql @@ -0,0 +1,12 @@ +CREATE SCHEMA IF NOT EXISTS tasknote; + +CREATE TABLE IF NOT EXISTS tasknote.users ( + id SERIAL, + email VARCHAR(100) NOT NULL, + password VARCHAR(255) NOT NULL, + admin BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL, + inactivated_at TIMESTAMP, + CONSTRAINT users_pk PRIMARY KEY (id), + CONSTRAINT users_email_uk UNIQUE (email) +);