diff --git a/pom.xml b/pom.xml index 2ea7b2c..ab61ac4 100644 --- a/pom.xml +++ b/pom.xml @@ -69,6 +69,18 @@ 8.7.0 + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + + + com.auth0 + java-jwt + 4.4.0 + + diff --git a/src/main/java/com/vodacom/falcon/config/SwaggerConfig.java b/src/main/java/com/vodacom/falcon/config/SwaggerConfig.java new file mode 100644 index 0000000..a94e077 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/config/SwaggerConfig.java @@ -0,0 +1,29 @@ +package com.vodacom.falcon.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition +@Slf4j +public class SwaggerConfig { + @Bean + public OpenAPI myOpenAPI() { + Contact contact = new Contact() + .email("faruquebraimo@gmail.com ") + .name("Faruque Braimo"); + + Info info = new Info() + .title("Falcon-API") + .version("1.0.0") + .contact(contact) + .description("Your travel assistant api"); + + return new OpenAPI().info(info); + } +} diff --git a/src/main/java/com/vodacom/falcon/config/security/CustomFilter.java b/src/main/java/com/vodacom/falcon/config/security/CustomFilter.java index bde1c24..2a6ae37 100644 --- a/src/main/java/com/vodacom/falcon/config/security/CustomFilter.java +++ b/src/main/java/com/vodacom/falcon/config/security/CustomFilter.java @@ -2,6 +2,7 @@ import com.vodacom.falcon.model.User; import com.vodacom.falcon.repository.UserRepository; +import com.vodacom.falcon.service.TokenService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -9,64 +10,43 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; 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.Base64; -import java.util.Objects; import static com.vodacom.falcon.util.FalconDefaults.AUTHORIZATION; -import static com.vodacom.falcon.util.FalconDefaults.BASIC; @Slf4j @Component @RequiredArgsConstructor public class CustomFilter extends OncePerRequestFilter { private final UserRepository userRepository; - private final PasswordEncoderProvider encoderProvider; + private final TokenService tokenService; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (isBasicAuth(request)) { - String base64 = this.getHeader(request) - .replace(BASIC, ""); - String[] credentials = new String(Base64.getDecoder() - .decode(base64)) - .split(":"); - - String username = credentials[0]; - String pass = credentials[1]; + String token = getToken(request); + if (token != null) { + var username = tokenService.validateToken(token); User user = userRepository.findByUsername(username); - if (Objects.isNull(user)) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("User not found!"); - return; - } - - if (encoderProvider.passwordEncoder().matches(user.getPassword(), pass)) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("Wrong Password"); - return; + if (user != null) { + var authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); } - - Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, null); - SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } - private boolean isBasicAuth(HttpServletRequest request) { - String header = getHeader(request); - return header != null && header.startsWith(BASIC); - } - private String getHeader(HttpServletRequest request) { - return request.getHeader(AUTHORIZATION); + private String getToken(HttpServletRequest request) { + String header = request.getHeader(AUTHORIZATION); + if (header == null) return null; + return header.replace("Bearer ", ""); } } diff --git a/src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java b/src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java index 4ca5e20..468b59f 100644 --- a/src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java +++ b/src/main/java/com/vodacom/falcon/config/security/SecurityConfig.java @@ -4,10 +4,14 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 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.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -20,6 +24,12 @@ public class SecurityConfig { @Autowired private CustomFilter filter; + private static final String[] AUTH_WHITELIST = { + "falcon/auth/**", + "falcon/insight/", + "/v3/api-docs/**", + "/swagger-ui/**" + }; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -28,11 +38,22 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(authorize -> authorize .dispatcherTypeMatchers(FORWARD, ERROR) .permitAll() - .requestMatchers("falcon/auth/**", "falcon/insight/**") + .requestMatchers(AUTH_WHITELIST) .permitAll() - .anyRequest().denyAll() + .anyRequest() + .authenticated() ) .addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class) .build(); } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/com/vodacom/falcon/config/security/UserDetails.java b/src/main/java/com/vodacom/falcon/config/security/UserDetails.java deleted file mode 100644 index 2bebf4e..0000000 --- a/src/main/java/com/vodacom/falcon/config/security/UserDetails.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.vodacom.falcon.config.security; - -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class UserDetails { - private String username; - private String password; -} diff --git a/src/main/java/com/vodacom/falcon/controller/AuthController.java b/src/main/java/com/vodacom/falcon/controller/AuthController.java index 2ab577f..4f8f276 100644 --- a/src/main/java/com/vodacom/falcon/controller/AuthController.java +++ b/src/main/java/com/vodacom/falcon/controller/AuthController.java @@ -1,10 +1,18 @@ package com.vodacom.falcon.controller; +import com.vodacom.falcon.model.request.AuthLoginRequest; import com.vodacom.falcon.model.request.UserRegistrationRequest; +import com.vodacom.falcon.model.response.TokenResponse; import com.vodacom.falcon.service.AuthService; -import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CrossOrigin; @@ -15,15 +23,34 @@ @RestController @CrossOrigin(value = "*") -@RequiredArgsConstructor + @Slf4j @RequestMapping("falcon/auth") public class AuthController { - private final AuthService service; + @Autowired + private AuthService service; - @PostMapping("/signup") - public ResponseEntity signup(@RequestBody UserRegistrationRequest userRegistrationRequest) { + @PostMapping("/signUp") + @Operation(summary = "Sign Up", description = "For user registration") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "400", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "500", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}) + }) + public ResponseEntity signUp(@RequestBody @Valid UserRegistrationRequest userRegistrationRequest) { service.createUser(userRegistrationRequest); return new ResponseEntity<>(HttpStatus.ACCEPTED); } + + @PostMapping("/signIn") + @Operation(summary = "Sign In", description = "For user login") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "400", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "500", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}) + }) + public ResponseEntity signIn(@RequestBody @Valid AuthLoginRequest authLoginRequest) { + service.login(authLoginRequest); + return ResponseEntity.ok(service.login(authLoginRequest)); + } } diff --git a/src/main/java/com/vodacom/falcon/controller/InsightController.java b/src/main/java/com/vodacom/falcon/controller/InsightController.java index eb22700..d06f05e 100644 --- a/src/main/java/com/vodacom/falcon/controller/InsightController.java +++ b/src/main/java/com/vodacom/falcon/controller/InsightController.java @@ -3,6 +3,12 @@ import com.vodacom.falcon.model.response.HistoricalEconomyInsightResponse; import com.vodacom.falcon.model.response.InsightResponse; import com.vodacom.falcon.service.InsightService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; @@ -25,14 +31,26 @@ public class InsightController { private final InsightService falconInsightService; @GetMapping() - public ResponseEntity getInsight(@RequestParam("city") String city) { + @Operation(summary = "Get insight by city", description = "Should return population, gdp, exchange rates, weather forecast. The last 2 if the user is authenticated") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "400", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "500", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}) + }) + public ResponseEntity getInsight(@RequestParam("city") @Valid String city) { InsightResponse response = falconInsightService.getInsight(city); return new ResponseEntity<>(response, HttpStatus.OK); } @GetMapping("/historical") + @Operation(summary = "Get historical insights by country", description = "Should return population and gdp from 2012 to 2022 for given country") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "400", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}), + @ApiResponse(responseCode = "500", content = {@Content(schema = @Schema(implementation = String.class), mediaType = "application/json")}) + }) @Cacheable(key = "#city", value = "historical") - public ResponseEntity getHistoricalInsights(@RequestParam("city") String city) throws InterruptedException { + public ResponseEntity getHistoricalInsights(@RequestParam("city") @Valid String city) { HistoricalEconomyInsightResponse response = falconInsightService.getHistoricalInsights(city); return new ResponseEntity<>(response, HttpStatus.OK); } diff --git a/src/main/java/com/vodacom/falcon/model/User.java b/src/main/java/com/vodacom/falcon/model/User.java index bca6b66..0604e22 100644 --- a/src/main/java/com/vodacom/falcon/model/User.java +++ b/src/main/java/com/vodacom/falcon/model/User.java @@ -11,8 +11,11 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; import java.time.LocalDateTime; +import java.util.Collection; import java.util.UUID; @Entity @@ -22,11 +25,16 @@ @NoArgsConstructor @Builder @Table(name = "falcon_user") -public class User { +public class User implements UserDetails { @Id @GeneratedValue(strategy = GenerationType.AUTO) private UUID id; private String username; private String password; private LocalDateTime createdAt; + + @Override + public Collection getAuthorities() { + return null; + } } diff --git a/src/main/java/com/vodacom/falcon/model/request/AuthLoginRequest.java b/src/main/java/com/vodacom/falcon/model/request/AuthLoginRequest.java new file mode 100644 index 0000000..98394d1 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/model/request/AuthLoginRequest.java @@ -0,0 +1,4 @@ +package com.vodacom.falcon.model.request; + +public record AuthLoginRequest(String username, String password) { +} diff --git a/src/main/java/com/vodacom/falcon/model/response/TokenResponse.java b/src/main/java/com/vodacom/falcon/model/response/TokenResponse.java new file mode 100644 index 0000000..eb09132 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/model/response/TokenResponse.java @@ -0,0 +1,4 @@ +package com.vodacom.falcon.model.response; + +public record TokenResponse (String token) { +} diff --git a/src/main/java/com/vodacom/falcon/service/AuthService.java b/src/main/java/com/vodacom/falcon/service/AuthService.java index c0faca7..6cbc692 100644 --- a/src/main/java/com/vodacom/falcon/service/AuthService.java +++ b/src/main/java/com/vodacom/falcon/service/AuthService.java @@ -3,20 +3,33 @@ import com.vodacom.falcon.config.security.PasswordEncoderProvider; import com.vodacom.falcon.model.User; +import com.vodacom.falcon.model.request.AuthLoginRequest; import com.vodacom.falcon.model.request.UserRegistrationRequest; +import com.vodacom.falcon.model.response.TokenResponse; import com.vodacom.falcon.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContext; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +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; import java.time.LocalDateTime; @Service -@RequiredArgsConstructor @Slf4j -public class AuthService { +@RequiredArgsConstructor +public class AuthService implements UserDetailsService { + private final PasswordEncoderProvider encoderProvider; + private final UserRepository userRepository; + private final ApplicationContext context; + private final TokenService tokenService; public void createUser(UserRegistrationRequest auth) { User existingUser = userRepository.findByUsername(auth.username()); @@ -30,5 +43,19 @@ public void createUser(UserRegistrationRequest auth) { .createdAt(LocalDateTime.now()) .build(); userRepository.save(user); + + } + + public TokenResponse login(AuthLoginRequest login) { + AuthenticationManager authenticationManager = context.getBean(AuthenticationManager.class); + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(login.username(), login.password()); + Authentication auth = authenticationManager.authenticate(authToken); + var token = tokenService.generateToken((User) auth.getPrincipal()); + return new TokenResponse(token); + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + return userRepository.findByUsername(username); } } diff --git a/src/main/java/com/vodacom/falcon/service/TokenService.java b/src/main/java/com/vodacom/falcon/service/TokenService.java new file mode 100644 index 0000000..887ff13 --- /dev/null +++ b/src/main/java/com/vodacom/falcon/service/TokenService.java @@ -0,0 +1,53 @@ +package com.vodacom.falcon.service; + +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.vodacom.falcon.model.User; +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 TokenService { + + @Value("${jwt.key}") + private String key; + + public String generateToken(User user) { + try { + Algorithm algorithm = Algorithm.HMAC256(key); + + return JWT.create() + .withIssuer("falcon") + .withSubject(user.getUsername()) + .withExpiresAt(getExpirationDate()) + .sign(algorithm); + } catch (IllegalArgumentException | JWTCreationException e) { + throw new RuntimeException("Error generating token", e); + } + } + + public String validateToken(String token) { + try { + Algorithm algorithm = Algorithm.HMAC256(key); + + return JWT.require(algorithm) + .withIssuer("falcon") + .build() + .verify(token) + .getSubject(); + } catch (JWTVerificationException exception) { + throw new RuntimeException("Token Not recognized"); + } + } + + + private Instant getExpirationDate() { + return LocalDateTime.now().plusHours(2).toInstant(ZoneOffset.of("-03:00")); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 67d251f..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -pring.application.name=voda-travel diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e448fb8..79b257d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,4 +23,7 @@ open-weather-map: main-exchange-rates-api: apiKey: c771abe4cb5cfe85674fae5142290f1a # ${exchangeRatesApiKey} optional-exchange-rates-api: - apiKey: dc7d9a4fe0e74557b0b83a1fcf66dd2a # ${exchangeRatesApiKey} \ No newline at end of file + apiKey: dc7d9a4fe0e74557b0b83a1fcf66dd2a # ${exchangeRatesApiKey} + +jwt: + key: 0cd1833b-62b4-415c-902f-1e69138fd2a8