Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

Commit

Permalink
Merge pull request #3 from BudgetBuddyDE/fix/auth
Browse files Browse the repository at this point in the history
Fix/auth
  • Loading branch information
tklein1801 authored Dec 3, 2023
2 parents e19c2bb + 38ec4b2 commit 4497981
Show file tree
Hide file tree
Showing 38 changed files with 1,202 additions and 348 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/publish-docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on:
- '*'

env:
IMAGE_NAME: ghcr.io/budgetbuddyde/backend
IMAGE_NAME: ghcr.io/BudgetBuddyDE/Backend
IMAGE_TAG: ${{ github.ref_name }}
DOCKER_USER: ${{ secrets.DOCKER_USER }}
PAT: ${{ secrets.NPM_TOKEN }}
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,37 @@ docker build . -t ghcr.io/budgetbuddyde/backend:latest
```bash
docker run -p 80:8080 ghcr.io/budgetbuddyde/backend:latest
```

## Authentification

> [!IMPORTANT]
> The `Authorization` header should be structured as follows.
>
> `Bearer: UUID.HASHED_PASSWORD`
>
> The values are separated and then verified in the `AuthorizationInterceptor`. The current user for the session is then determined based on the UUID and set as the "user" session attribute.
```mermaid
---
title: Backend Authentification Flow
---
flowchart TD
401[HTTP 401]
500[HTTP 500]
validation_end((End))
start((Start)) -->|Incoming Request| path_match{URI matches /v1/auth/**}
path_match -->|Yes| validation_end
path_match -->|No| has_auth_header{Check if Auth-Header exists \nand provides 'Bearer'}
has_auth_header -->|No| 401[Set HTTP 401 Unauthorized. Reason: No Bearer-Token we're provided]
has_auth_header -->|Yes| get_token_bearer[Extract UUID and hashed password from Bearer token]
get_token_bearer --> validate_bearer_is_UUID{Validate if Bearer is a UUID}
validate_bearer_is_UUID -->|No, throw IllegalArgumentException| 500[Set HTTP 500 Internal Server Error]
validate_bearer_is_UUID -->|Yes| retrieve_user[Retrieve User by UUID and password from UserRepository]
retrieve_user --> is_user_present{Check if User is found}
is_user_present -->|No| 401[Set HTTP 401 Unauthorized. Reason: Provided Bearer-Token is invalid]
is_user_present -->|Yes| serialize_user_to_string[Serialize user to String using ObjectMapper]
serialize_user_to_string -->|On JsonProcessingException| 500[Set HTTP 500 Internal Server Error]
serialize_user_to_string -->|No Exception| store_to_session[Store serialized user to HTTP Session]
store_to_session --> validation_end
```
2 changes: 0 additions & 2 deletions src/main/java/de/budgetbuddy/backend/ApiResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

import lombok.Data;

import java.util.ArrayList;

@Data
public class ApiResponse<T> {
private int status;
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/de/budgetbuddy/backend/BackendApplication.java
Original file line number Diff line number Diff line change
@@ -1,15 +1,30 @@
package de.budgetbuddy.backend;

import de.budgetbuddy.backend.log.LogRepository;
import de.budgetbuddy.backend.log.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@SpringBootApplication
public class BackendApplication {
@Autowired
public BackendApplication(LogRepository logRepository) {
Logger.getInstance().setLogRepository(logRepository);
}

public static void main(String[] args) {
SpringApplication.run(BackendApplication.class, args);
}

@GetMapping("/auth_test")
ResponseEntity<ApiResponse<String>> test_authorization_interceptor() {
return ResponseEntity
.status(200)
.body(new ApiResponse<>("OK"));
}
}
56 changes: 51 additions & 5 deletions src/main/java/de/budgetbuddy/backend/auth/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@
import de.budgetbuddy.backend.ApiResponse;
import de.budgetbuddy.backend.user.User;
import de.budgetbuddy.backend.user.UserRepository;
import de.budgetbuddy.backend.user.role.Role;
import de.budgetbuddy.backend.user.role.RolePermission;
import jakarta.servlet.http.HttpSession;
import org.apache.coyote.Response;
import org.hibernate.engine.transaction.internal.jta.JtaStatusHelper;
import org.mindrot.jbcrypt.BCrypt;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
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;
import org.springframework.web.bind.annotation.*;

import java.util.Objects;
import java.util.Optional;

@RestController
Expand All @@ -30,7 +33,10 @@ public AuthController(UserRepository userRepository) {
}

@PostMapping(value = "/register")
public ResponseEntity<ApiResponse<User>> register(@RequestBody User user) {
public ResponseEntity<ApiResponse<User>> register(
@RequestBody User user,
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorizationHeader
) {
Optional<User> optionalUser = userRepository.findByEmail(user.getEmail());
if (optionalUser.isPresent()) {
return ResponseEntity
Expand All @@ -39,6 +45,46 @@ public ResponseEntity<ApiResponse<User>> register(@RequestBody User user) {
}

user.hashPassword();
if (user.getRole() == null) {
user.setRole(new Role(RolePermission.BASIC));
} else if (user.getRole().isGreaterOrEqualThan(RolePermission.SERVICE_ACCOUNT)) {
if (Objects.isNull(authorizationHeader) || authorizationHeader.isEmpty()) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse<>(
HttpStatus.UNAUTHORIZED.value(),
"You need to verify yourself in order to proceed"));
}

AuthorizationInterceptor.AuthValues authValues = AuthorizationInterceptor
.retrieveTokenValue(authorizationHeader);
if (authValues.getUuid() == null || authValues.getHashedPassword() == null) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse<>(
HttpStatus.UNAUTHORIZED.value(),
"Your provided Bearer-Token is not correctly formatted"));
}

Optional<User> authUser = userRepository
.findByUuidAndPassword(authValues.getUuid(), authValues.getHashedPassword());
if (authUser.isEmpty()) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse<>(
HttpStatus.UNAUTHORIZED.value(),
"Your provided credentials are invalid"));
}

if (!authUser.get().getRole().isGreaterOrEqualThan(RolePermission.ADMIN)) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ApiResponse<>(
HttpStatus.UNAUTHORIZED.value(),
"You don't have the permissions to create this user"));
}
}

return ResponseEntity
.status(HttpStatus.OK)
.body(new ApiResponse<>(userRepository.save(user)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import de.budgetbuddy.backend.ApiResponse;
import de.budgetbuddy.backend.config.RequestLoggingInterceptor;
import de.budgetbuddy.backend.user.User;
import de.budgetbuddy.backend.user.UserRepository;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.AntPathMatcher;
Expand All @@ -20,6 +23,7 @@
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletRequest;

import java.io.IOException;
import java.util.Optional;
import java.util.UUID;

Expand All @@ -32,8 +36,17 @@ public AuthorizationInterceptor(UserRepository userRepository) {
this.userRepository = userRepository;
}

/**
* Important
* The Authorization header should be structured as follows.
* `Bearer: UUID.HASHED_PASSWORD`
* The values are separated and then verified in the `AuthorizationInterceptor`. The current user for the session is then determined based on the UUID and set as the "user" session attribute.
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
public boolean preHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
PathMatcher pathMatcher = new AntPathMatcher();
String path = request.getRequestURI();
if (pathMatcher.match("/v1/auth/**", path)) {
Expand All @@ -42,22 +55,22 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons

try {
String authHeader = request.getHeader("Authorization");

if (authHeader == null || !authHeader.startsWith("Bearer")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
ApiResponse<?> apiResponse = new ApiResponse<>(HttpServletResponse.SC_UNAUTHORIZED, "No Bearer-Token we're provided");
response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponse));
handleUnauthorizedResponse(request, response, "No Bearer-Token was provided");
return false;
}

AuthValues authValues = AuthorizationInterceptor.retrieveTokenValue(authHeader);
if (authValues.getUuid() == null || authValues.getHashedPassword() == null) {
handleUnauthorizedResponse(request, response, "Invalid Bearer-Token format");
return false;
}

String bearerValue = authHeader.substring("Bearer".length() + 1);
UUID uuid = UUID.fromString(bearerValue);
Optional<User> optAuthHeaderUser = userRepository.findById(uuid);
Optional<User> optAuthHeaderUser = userRepository
.findByUuidAndPassword(authValues.getUuid(), authValues.getHashedPassword());
if (optAuthHeaderUser.isEmpty()) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
ApiResponse<?> apiResponse = new ApiResponse<>(HttpServletResponse.SC_UNAUTHORIZED, "Provided Bearer-Token is invalid");
response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponse));
handleUnauthorizedResponse(request, response, "Provided Bearer-Token is invalid");
return false;
}

Expand All @@ -66,14 +79,54 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
session.setAttribute("user", objMapper.writeValueAsString(authHeaderUser));
return true;
} catch (IllegalArgumentException | JsonProcessingException ex) {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("application/json");
ApiResponse<String> apiResponse = new ApiResponse<String>(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "internal-server-error", ex.getMessage());
response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponse));
handleErrorResponse(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "internal-server-error", ex.getMessage());
return false;
}
}

public static AuthValues retrieveTokenValue(String authorizationHeader) {
String bearerTokenValue = authorizationHeader.substring("Bearer".length() + 1);
int indexOfFirstDot = bearerTokenValue.indexOf(".");
if (indexOfFirstDot == -1) return new AuthValues();

UUID uuid = UUID.fromString(bearerTokenValue.substring(0, indexOfFirstDot));
String hashedPassword = bearerTokenValue.substring(indexOfFirstDot + 1);
return new AuthValues(uuid, hashedPassword);
}

@Data
@AllArgsConstructor
public static class AuthValues {
private UUID uuid;
private String hashedPassword;

public AuthValues() {}
}

private void handleUnauthorizedResponse(
HttpServletRequest request,
HttpServletResponse response,
String errorMessage) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
ApiResponse<?> apiResponse = new ApiResponse<>(HttpServletResponse.SC_UNAUTHORIZED, errorMessage);
response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponse));
RequestLoggingInterceptor.logRequest(request, response);
}

private void handleErrorResponse(
HttpServletRequest request,
HttpServletResponse response,
int statusCode,
String errorType,
String errorMessage) throws IOException {
response.setStatus(statusCode);
response.setContentType("application/json");
ApiResponse<String> apiResponse = new ApiResponse<>(statusCode, errorType, errorMessage);
response.getWriter().write(new ObjectMapper().writeValueAsString(apiResponse));
RequestLoggingInterceptor.logRequest(request, response);
}

public static boolean isValidUserSession(HttpSession session) {
return session != null && session.getAttribute("user") != null;
}
Expand All @@ -95,12 +148,20 @@ public static <T> ResponseEntity<ApiResponse<T>> noValidSessionResponse() {
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable ModelAndView modelAndView) throws Exception {
public void postHandle(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
@Nullable ModelAndView modelAndView) throws Exception {
HandlerInterceptor.super.postHandle(request, response, handler, modelAndView);
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
@Nullable Exception ex) throws Exception {
HandlerInterceptor.super.afterCompletion(request, response, handler, ex);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ public ResponseEntity<ApiResponse<Budget>> updateBudget(@RequestBody Budget.Upda
}

Budget updatedBudget = new Budget(budget.getId(), category.get(), budget.getOwner(), payload.getBudget(), budget.getCreatedAt());
if (budgetRepository.findByOwnerAndCategory(budget.getOwner(), category.get()).isPresent()) {
Optional<Budget> existingBudget = budgetRepository.findByOwnerAndCategory(budget.getOwner(), category.get());
if (existingBudget.isPresent() && !existingBudget.get().getId().equals(payload.getBudgetId())) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ApiResponse<>(HttpStatus.CONFLICT.value(), "There is already an budget for this category"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ public ResponseEntity<ApiResponse<Category>> updateCategory(@RequestBody Categor
.body(new ApiResponse<>(HttpStatus.CONFLICT.value(), "You can't modify categories from other users"));
}

if (categoryRepository.findByOwnerAndName(sessionUser.get(), payload.getName()).isPresent()) {
Optional<Category> alreadyExistingCategory = categoryRepository.findByOwnerAndName(sessionUser.get(), payload.getName());
if (alreadyExistingCategory.isPresent()
&& !alreadyExistingCategory.get().getId().equals(payload.getCategoryId())) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ApiResponse<>(HttpStatus.CONFLICT.value(), "There is already an category by this name"));
Expand Down
3 changes: 1 addition & 2 deletions src/main/java/de/budgetbuddy/backend/config/CorsConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ public class CorsConfig implements WebMvcConfigurer {
public void addCorsMappings(CorsRegistry registry) {
registry
.addMapping("/**")
.allowedOrigins("http://127.0.0.1:5173", "https://budget-buddy.de")
.allowedOriginPatterns("http://*localhost*", "https://*budget-buddy.de*")
.allowedOriginPatterns("http://localhost:5173", "http://localhost:3000", "https://*budget-buddy.de*")
.allowedMethods("*")
.allowedHeaders("*")
.allowCredentials(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,19 @@
import de.budgetbuddy.backend.ApiResponse;
import de.budgetbuddy.backend.log.Log;
import de.budgetbuddy.backend.log.LogType;
import de.budgetbuddy.backend.log.Logger;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class CustomErrorController {

@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<String> handleInternalServerError(Exception ex) {
Log log = new Log(LogType.ERROR, "/error", ex.toString());
System.out.println(log);

Log log = new Log("Backend", LogType.ERROR, "/error", ex.toString());
Logger.getInstance().log(log);
return new ApiResponse<>(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
ex.getMessage(),
Expand Down
Loading

0 comments on commit 4497981

Please sign in to comment.