Browse Source

implement authentication and owned portfolios (#5)

Daniel Bohry 8 months ago
parent
commit
0020e35575

+ 1 - 1
build.gradle

@@ -1,6 +1,6 @@
 plugins {
     id 'java'
-    id 'org.springframework.boot' version '3.2.3'
+    id 'org.springframework.boot' version '3.4.3'
     id 'io.spring.dependency-management' version '1.1.4'
 }
 

+ 6 - 0
src/main/java/com/danielbohry/stocks/api/GlobalExceptionHandler.java

@@ -2,6 +2,7 @@ package com.danielbohry.stocks.api;
 
 
 import com.danielbohry.stocks.exception.Error;
+import com.danielbohry.stocks.exception.UnauthorizedException;
 import feign.FeignException;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
@@ -22,4 +23,9 @@ public class GlobalExceptionHandler {
         return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new Error("Too many requests"));
     }
 
+    @ExceptionHandler(UnauthorizedException.class)
+    public ResponseEntity<Error> handleUnauthorizedException(UnauthorizedException e, WebRequest request) {
+        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new Error(e.getMessage()));
+    }
+
 }

+ 9 - 0
src/main/java/com/danielbohry/stocks/api/portfolio/PortfolioController.java

@@ -1,5 +1,6 @@
 package com.danielbohry.stocks.api.portfolio;
 
+import com.danielbohry.stocks.context.UserContextHolder;
 import com.danielbohry.stocks.domain.Portfolio;
 import com.danielbohry.stocks.service.PortfolioService;
 import io.swagger.v3.oas.annotations.Hidden;
@@ -8,6 +9,8 @@ import org.springframework.http.HttpStatus;
 import org.springframework.http.ResponseEntity;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.List;
+
 import static com.danielbohry.stocks.api.portfolio.PortfolioResponse.to;
 
 @RestController
@@ -18,6 +21,12 @@ public class PortfolioController {
 
     private final PortfolioService service;
 
+    @GetMapping
+    public ResponseEntity<?> current() {
+        List<Portfolio> response = service.getByUser(UserContextHolder.get().getUsername());
+        return ResponseEntity.ok(response);
+    }
+
     @GetMapping("{id}")
     public ResponseEntity<Portfolio> get(@PathVariable String id) {
         Portfolio response = service.get(id);

+ 31 - 0
src/main/java/com/danielbohry/stocks/api/user/UserController.java

@@ -0,0 +1,31 @@
+package com.danielbohry.stocks.api.user;
+
+import com.danielbohry.stocks.api.user.dto.RegisterRequest;
+import com.danielbohry.stocks.domain.Login;
+import com.danielbohry.stocks.service.UserService;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("api/users")
+@AllArgsConstructor
+@CrossOrigin
+public class UserController {
+
+    private final UserService service;
+
+    @PostMapping("register")
+    public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
+        Login response = service.register(request.username(), request.password());
+        return ResponseEntity.status(HttpStatus.CREATED).body(response);
+    }
+
+    @PostMapping("login")
+    public ResponseEntity<?> login(@RequestBody RegisterRequest request) {
+        Login response = service.login(request.username(), request.password());
+        return ResponseEntity.ok(response);
+    }
+
+}

+ 4 - 0
src/main/java/com/danielbohry/stocks/api/user/dto/RegisterRequest.java

@@ -0,0 +1,4 @@
+package com.danielbohry.stocks.api.user.dto;
+
+public record RegisterRequest(String username, String password) {
+}

+ 7 - 0
src/main/java/com/danielbohry/stocks/api/user/dto/UserResponse.java

@@ -0,0 +1,7 @@
+package com.danielbohry.stocks.api.user.dto;
+
+import java.time.Instant;
+import java.util.List;
+
+public record UserResponse(String username, String description, List<String> followers, List<String> following, Instant createdAt, Instant updatedAt) {
+}

+ 113 - 0
src/main/java/com/danielbohry/stocks/client/AuthClient.java

@@ -0,0 +1,113 @@
+package com.danielbohry.stocks.client;
+
+import com.danielbohry.stocks.exception.UnauthorizedException;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.HttpClientErrorException;
+import org.springframework.web.client.RestTemplate;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+
+@Component
+@RequiredArgsConstructor
+public class AuthClient {
+
+    private final RestTemplate rest;
+
+    @Value("${auth.api}")
+    private String BASE_URL;
+
+    public AuthenticatedUser register(String username, String password) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+
+        Map<String, String> requestBody = new HashMap<>();
+        requestBody.put("username", username);
+        requestBody.put("password", password);
+
+        HttpEntity<Map<String, String>> entity = new HttpEntity<>(requestBody, headers);
+
+        try {
+            ResponseEntity<AuthenticatedUser> response = rest.exchange(BASE_URL + "/register", HttpMethod.POST, entity, AuthenticatedUser.class);
+
+            if (response.getStatusCode() == HttpStatus.OK) {
+                return response.getBody();
+            } else {
+                throw new UnauthorizedException("Unexpected response status: " + response.getStatusCode());
+            }
+        } catch (HttpClientErrorException e) {
+            throw new UnauthorizedException("Error: " + e.getStatusCode() + " - " + e.getResponseBodyAsString());
+        } catch (Exception e) {
+            throw new UnauthorizedException("An error occurred: " + e.getMessage());
+        }
+    }
+
+    public AuthenticatedUser authenticate(String username, String password) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+
+        Map<String, String> requestBody = new HashMap<>();
+        requestBody.put("username", username);
+        requestBody.put("password", password);
+
+        HttpEntity<Map<String, String>> entity = new HttpEntity<>(requestBody, headers);
+
+        try {
+            ResponseEntity<AuthenticatedUser> response = rest.exchange(BASE_URL + "/authenticate", HttpMethod.POST, entity, AuthenticatedUser.class);
+            if (response.getStatusCode() == HttpStatus.OK) {
+                return response.getBody();
+            } else {
+                throw new UnauthorizedException("Unexpected response status: " + response.getStatusCode());
+            }
+        } catch (HttpClientErrorException e) {
+            throw new UnauthorizedException("Error: " + e.getStatusCode() + " - " + e.getResponseBodyAsString());
+        } catch (Exception e) {
+            throw new UnauthorizedException("An error occurred: " + e.getMessage());
+        }
+    }
+
+    public CurrentUser getCurrent(String token) {
+        HttpHeaders headers = new HttpHeaders();
+        headers.add("Authorization", token);
+
+        HttpEntity<Map<String, String>> entity = new HttpEntity<>(null, headers);
+
+        try {
+            ResponseEntity<CurrentUser> response = rest.exchange(BASE_URL + "/users/current", HttpMethod.GET, entity, CurrentUser.class);
+
+            if (response.getStatusCode() == HttpStatus.OK) {
+                return response.getBody();
+            } else {
+                throw new UnauthorizedException("Unexpected response status: " + response.getStatusCode());
+            }
+        } catch (HttpClientErrorException e) {
+            throw new UnauthorizedException("Error: " + e.getStatusCode() + " - " + e.getResponseBodyAsString());
+        } catch (Exception e) {
+            throw new UnauthorizedException("An error occurred: " + e.getMessage());
+        }
+    }
+
+    @Data
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class AuthenticatedUser {
+        private String username;
+        private String token;
+        private Instant expirationDate;
+    }
+
+    @Data
+    @AllArgsConstructor
+    @NoArgsConstructor
+    public static class CurrentUser {
+        private String username;
+    }
+
+}

+ 15 - 0
src/main/java/com/danielbohry/stocks/config/RestConfig.java

@@ -0,0 +1,15 @@
+package com.danielbohry.stocks.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestConfig {
+
+    @Bean
+    RestTemplate rest() {
+        return new RestTemplate();
+    }
+
+}

+ 65 - 0
src/main/java/com/danielbohry/stocks/context/ServiceContextFilter.java

@@ -0,0 +1,65 @@
+package com.danielbohry.stocks.context;
+
+import com.danielbohry.stocks.client.AuthClient;
+import com.danielbohry.stocks.client.AuthClient.CurrentUser;
+import com.danielbohry.stocks.exception.UnauthorizedException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+
+@Component
+@AllArgsConstructor
+public class ServiceContextFilter extends OncePerRequestFilter {
+
+    private final AuthClient authClient;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, jakarta.servlet.FilterChain filterChain) throws jakarta.servlet.ServletException, IOException {
+        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
+            response.setStatus(HttpServletResponse.SC_OK);
+            response.setHeader("Access-Control-Allow-Origin", "*");
+            response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
+            response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
+            response.setHeader("Access-Control-Max-Age", "3600");
+            return;
+        }
+
+        String path = request.getRequestURI();
+        if (path.contains("/register") || path.contains("/login") || path.contains("/actuator")) {
+            filterChain.doFilter(request, response);
+            return;
+        }
+
+        try {
+            CurrentUser user = extractCurrentUser(request);
+
+            if (user != null) {
+                UserContextHolder.set(new UserContext(user.getUsername()));
+
+                try {
+                    filterChain.doFilter(request, response);
+                } finally {
+                    UserContextHolder.clear();
+                }
+            } else {
+                response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized: User not found");
+            }
+        } catch (UnauthorizedException e) {
+            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized: Invalid token");
+        }
+    }
+
+    private CurrentUser extractCurrentUser(HttpServletRequest request) {
+        String token = request.getHeader("Authorization");
+
+        return token != null ?
+            authClient.getCurrent(token)
+            : new CurrentUser("anonymous");
+    }
+
+}

+ 12 - 0
src/main/java/com/danielbohry/stocks/context/UserContext.java

@@ -0,0 +1,12 @@
+package com.danielbohry.stocks.context;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public class UserContext {
+
+    private String username;
+
+}

+ 19 - 0
src/main/java/com/danielbohry/stocks/context/UserContextHolder.java

@@ -0,0 +1,19 @@
+package com.danielbohry.stocks.context;
+
+public class UserContextHolder {
+
+    private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>();
+
+    public static void set(UserContext serviceContext) {
+        CONTEXT.set(serviceContext);
+    }
+
+    public static UserContext get() {
+        return CONTEXT.get();
+    }
+
+    public static void clear() {
+        CONTEXT.remove();
+    }
+
+}

+ 18 - 0
src/main/java/com/danielbohry/stocks/domain/Login.java

@@ -0,0 +1,18 @@
+package com.danielbohry.stocks.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.Instant;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class Login {
+
+    private String username;
+    private String token;
+    private Instant expiresAt;
+
+}

+ 1 - 0
src/main/java/com/danielbohry/stocks/domain/Portfolio.java

@@ -15,6 +15,7 @@ public class Portfolio {
     private List<Stock> stocks;
     private BigDecimal totalValue;
     private Integer totalAssets;
+    private String username;
     private LocalDateTime createdAt;
     private LocalDateTime updatedAt;
 

+ 22 - 0
src/main/java/com/danielbohry/stocks/domain/User.java

@@ -0,0 +1,22 @@
+package com.danielbohry.stocks.domain;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.Id;
+
+import java.time.Instant;
+import java.util.List;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class User {
+
+    @Id
+    private String username;
+    private List<String> portfolios;
+    private Instant createdAt = Instant.now();
+    private Instant updatedAt = Instant.now();
+
+}

+ 12 - 0
src/main/java/com/danielbohry/stocks/exception/UnauthorizedException.java

@@ -0,0 +1,12 @@
+package com.danielbohry.stocks.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@ResponseStatus(value = HttpStatus.UNAUTHORIZED)
+public class UnauthorizedException extends RuntimeException {
+
+    public UnauthorizedException(String message) {
+        super(message);
+    }
+}

+ 2 - 0
src/main/java/com/danielbohry/stocks/repository/PortfolioRepository.java

@@ -13,4 +13,6 @@ public interface PortfolioRepository extends MongoRepository<Portfolio, String>
     @Query("{ 'stocks': { $size: 0 } }")
     List<Portfolio> findAllByEmptyStocks();
 
+    List<Portfolio> findAllByUsername(String username);
+
 }

+ 10 - 0
src/main/java/com/danielbohry/stocks/repository/UserRepository.java

@@ -0,0 +1,10 @@
+package com.danielbohry.stocks.repository;
+
+import com.danielbohry.stocks.domain.User;
+import org.springframework.data.mongodb.repository.MongoRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface UserRepository extends MongoRepository<User, String> {
+
+}

+ 44 - 33
src/main/java/com/danielbohry/stocks/service/PortfolioService.java

@@ -1,47 +1,57 @@
 package com.danielbohry.stocks.service;
 
+import com.danielbohry.stocks.context.UserContextHolder;
 import com.danielbohry.stocks.domain.Portfolio;
 import com.danielbohry.stocks.domain.Quote;
 import com.danielbohry.stocks.domain.Stock;
 import com.danielbohry.stocks.exception.BadRequestException;
 import com.danielbohry.stocks.exception.NotFoundException;
+import com.danielbohry.stocks.exception.UnauthorizedException;
 import com.danielbohry.stocks.repository.PortfolioRepository;
 import com.danielbohry.stocks.service.ExchangeService.ExchangeRateResponse;
+import lombok.AllArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
 import java.math.BigDecimal;
-import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.UUID;
+import java.util.stream.Collectors;
 
 import static java.math.RoundingMode.HALF_UP;
 import static java.time.LocalDateTime.now;
+import static java.util.Collections.emptyList;
 
-@Service
 @Slf4j
+@Service
+@AllArgsConstructor
 public class PortfolioService {
 
     private final PortfolioRepository repository;
     private final StockService stockService;
     private final ExchangeService exchangeService;
 
-    public PortfolioService(PortfolioRepository repository,
-                            StockService stockService,
-                            ExchangeService exchangeService) {
-        this.repository = repository;
-        this.stockService = stockService;
-        this.exchangeService = exchangeService;
-    }
-
     public List<Portfolio> getAll() {
         return repository.findAll();
     }
 
+    public List<Portfolio> getByUser(String username) {
+        List<Portfolio> portfolios = repository.findAllByUsername(username);
+
+        return portfolios.stream()
+            .map(portfolio -> get(portfolio.getId()))
+            .toList();
+    }
+
     public Portfolio get(String id) {
         Portfolio portfolio = repository.findById(id)
-                .orElseThrow(() -> new NotFoundException("No portfolio found with id: " + id));
+            .orElseThrow(() -> new NotFoundException("No portfolio found with id: " + id));
+
+        if (portfolio.getUsername() != null && !Objects.equals(portfolio.getUsername(), UserContextHolder.get().getUsername())) {
+            throw new UnauthorizedException("You do not have permission to access this portfolio");
+        }
 
         ExchangeRateResponse exchangeRate = exchangeService.getCurrentRate("USD");
 
@@ -50,22 +60,22 @@ public class PortfolioService {
 
         log.info("Getting portfolio [{}]", id);
         List<Stock> updatedStocks = portfolio.getStocks().stream()
-                .peek(stock -> {
-                    Quote quote = stockService.getStockQuote(stock.getCode());
-                    stock.setName(quote.getName());
-
-                    switch (quote.getCurrency()) {
-                        case "BRL":
-                            stock.setPrice(quote.getPrice().divide(brlRatio, HALF_UP));
-                            break;
-                        case "EUR":
-                            stock.setPrice(quote.getPrice().divide(eurRatio, HALF_UP));
-                        default:
-                            stock.setPrice(quote.getPrice());
-                    }
-
-                    stock.setTotal(stock.getPrice().multiply(new BigDecimal(stock.getQuantity())));
-                }).toList();
+            .peek(stock -> {
+                Quote quote = stockService.getStockQuote(stock.getCode());
+                stock.setName(quote.getName());
+
+                switch (quote.getCurrency()) {
+                    case "BRL":
+                        stock.setPrice(quote.getPrice().divide(brlRatio, HALF_UP));
+                        break;
+                    case "EUR":
+                        stock.setPrice(quote.getPrice().divide(eurRatio, HALF_UP));
+                    default:
+                        stock.setPrice(quote.getPrice());
+                }
+
+                stock.setTotal(stock.getPrice().multiply(new BigDecimal(stock.getQuantity())));
+            }).toList();
 
         portfolio.setStocks(updatedStocks);
 
@@ -76,11 +86,12 @@ public class PortfolioService {
         String id = UUID.randomUUID().toString();
 
         Portfolio toSave = Portfolio.builder()
-                .id(id)
-                .stocks(Collections.emptyList())
-                .createdAt(now())
-                .updatedAt(now())
-                .build();
+            .id(id)
+            .stocks(emptyList())
+            .username(UserContextHolder.get().getUsername())
+            .createdAt(now())
+            .updatedAt(now())
+            .build();
 
         return repository.save(toSave);
     }
@@ -117,7 +128,7 @@ public class PortfolioService {
 
     private void validate(List<Stock> stocks) {
         boolean anyInvalid = stocks.stream()
-                .anyMatch(stock -> !stockService.isValid(stock.getCode()));
+            .anyMatch(stock -> !stockService.isValid(stock.getCode()));
 
         if (anyInvalid) {
             throw new BadRequestException("Invalid stock found");

+ 58 - 0
src/main/java/com/danielbohry/stocks/service/UserService.java

@@ -0,0 +1,58 @@
+package com.danielbohry.stocks.service;
+
+import com.danielbohry.stocks.client.AuthClient;
+import com.danielbohry.stocks.domain.Login;
+import com.danielbohry.stocks.domain.User;
+import com.danielbohry.stocks.exception.BadRequestException;
+import com.danielbohry.stocks.exception.NotFoundException;
+import com.danielbohry.stocks.exception.UnauthorizedException;
+import com.danielbohry.stocks.repository.UserRepository;
+import lombok.AllArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+
+import static java.util.Collections.emptyList;
+
+@Service
+@AllArgsConstructor
+public class UserService {
+
+    private final AuthClient authClient;
+    private final UserRepository repository;
+
+    public Login register(String username, String password) {
+        if (repository.findById(username).isPresent()) {
+            throw new BadRequestException("Username already exists");
+        }
+
+        if (username.length() > 30) {
+            throw new BadRequestException("Username too long");
+        }
+
+        try {
+            AuthClient.AuthenticatedUser auth = authClient.register(username, password);
+            User user = repository.save(new User(username, emptyList(), Instant.now(), Instant.now()));
+
+            return new Login(user.getUsername(), auth.getToken(), auth.getExpirationDate());
+        } catch (Exception e) {
+            throw new UnauthorizedException(e.getMessage());
+        }
+
+    }
+
+    public Login login(String username, String password) {
+        User user = repository.findById(username)
+            .orElseThrow(() -> new NotFoundException("User not found"));
+
+        try {
+            AuthClient.AuthenticatedUser auth = authClient.authenticate(username, password);
+
+            return new Login(user.getUsername(), auth.getToken(), auth.getExpirationDate());
+        } catch (Exception e) {
+            throw new UnauthorizedException(e.getMessage());
+        }
+
+    }
+
+}

+ 3 - 0
src/main/resources/application.yml

@@ -9,6 +9,9 @@ clients:
     url: ${exchange_provider:https://v6.exchangerate-api.com/v6}
     key: ${exchange_key:}
 
+auth:
+  api: ${auth_api:}
+
 spring:
   data:
     mongodb: