Public Access
Cleaned up the architecture
This commit is contained in:
1 parent
cc24c43ab8
commit
161381ebe5
17 files changed
+297
-46
No files matched your search
@@ -0,0 +1,82 @@
|
|||||||
|
# To Be Played (TBP) - Authentication Service
|
||||||
|
|
||||||
|
This is the Authentication and User Management Service for the "To Be Played" project. It acts as the central identity provider, handling user authentication, JWT generation, and profile management for the TBP frontend.
|
||||||
|
|
||||||
|
## 🚀 Tech Stack
|
||||||
|
|
||||||
|
* **Language:** Java 25
|
||||||
|
* **Framework:** Spring Boot 4.1.x
|
||||||
|
* **Security:** Spring Security & JSON Web Tokens (JWT)
|
||||||
|
* **Database:** PostgreSQL 17
|
||||||
|
* **Migrations:** Flyway
|
||||||
|
* **Observability:** Spring Boot Actuator & Micrometer (Prometheus)
|
||||||
|
* **Build Tool:** Maven Wrapper (`mvnw`)
|
||||||
|
* **CI/CD:** Gitea Actions (Forgejo)
|
||||||
|
|
||||||
|
## 🏗️ Architecture (Clean Architecture)
|
||||||
|
|
||||||
|
The project strictly follows a Domain-Driven / Clean Architecture (Ports & Adapters) approach to ensure high maintainability and testability. The codebase is divided into three main layers to prevent framework lock-in and keep business logic isolated.
|
||||||
|
|
||||||
|
### Layer Breakdown
|
||||||
|
|
||||||
|
1. **Controller / Web Layer (`controller`)**
|
||||||
|
* Contains REST endpoints, DTOs (`Request`/`Response` records), and Global Exception Handlers.
|
||||||
|
* Uses `ApiMapper` to map between DTOs and Domain Models.
|
||||||
|
* **Rule:** This layer delegates to the Service layer and never accesses persistence directly.
|
||||||
|
|
||||||
|
2. **Domain / Core Layer (`model`, `service`, `exception`)**
|
||||||
|
* The heart of the application. Contains pure Java classes (Models) and Business Logic (Services).
|
||||||
|
* Throws specific business exceptions (e.g., `InvalidCredentialsException`).
|
||||||
|
* **Rule:** This layer has **no dependencies** on the Controller or Persistence layer. It defines interfaces that the persistence layer must implement.
|
||||||
|
|
||||||
|
3. **Persistence Layer (`persistence`)**
|
||||||
|
* Handles database operations. Contains JPA Entities (`UserEntity`), Spring Data Repositories, and the `EntityMapper`.
|
||||||
|
* Implements the interfaces defined in the Domain layer.
|
||||||
|
* **Rule:** Entities never leak into the Controller layer.
|
||||||
|
|
||||||
|
*Note: Future implementations will include ArchUnit tests to automatically enforce these architectural boundaries during the CI pipeline.*
|
||||||
|
|
||||||
|
## ⚙️ Local Development
|
||||||
|
|
||||||
|
Running the application locally is fully automated via Spring Boot's Docker Compose Support.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
* Java 25 installed
|
||||||
|
* Docker & Docker Compose running
|
||||||
|
|
||||||
|
### How to start
|
||||||
|
|
||||||
|
1. Simply run the application from your IDE (e.g., IntelliJ) via the `AuthServiceApplication.java` main class.
|
||||||
|
2. Spring Boot will automatically detect the `compose.yaml` and spin up a local PostgreSQL container (`tbp_auth_db_local`) on port `5432`.
|
||||||
|
3. **Flyway** will automatically apply all database migrations located in `src/main/resources/db/migration`.
|
||||||
|
4. The application will be available at `http://localhost:8080`.
|
||||||
|
|
||||||
|
To stop the environment and clean the database volume (if needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 API Versioning
|
||||||
|
|
||||||
|
All API endpoints utilize Query Parameter Versioning to keep URLs clean and manageable.
|
||||||
|
Example: GET /api/users/me?v=1
|
||||||
|
|
||||||
|
## 🐳 CI/CD Pipeline
|
||||||
|
|
||||||
|
The project uses Gitea Actions for Continuous Integration and Deployment.
|
||||||
|
|
||||||
|
Quality Gate: Runs ./mvnw clean verify to ensure tests and builds pass.
|
||||||
|
|
||||||
|
Docker Build: Packages the application using a multi-stage Dockerfile.
|
||||||
|
|
||||||
|
Registry: Pushes the lightweight Alpine-based image to the private Gitea container registry (git.andreas-dahm.eu/lobo_corp/tbp-auth-service:latest).
|
||||||
|
|
||||||
|
## 📈 Observability
|
||||||
|
|
||||||
|
Metrics and health checks are exposed out-of-the-box via Spring Boot Actuator:
|
||||||
|
|
||||||
|
Health: http://localhost:8080/actuator/health
|
||||||
|
|
||||||
|
Prometheus Metrics: http://localhost:8080/actuator/prometheus
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
package auth_service.domain.login.controller;
|
package auth_service.domain.login.controller;
|
||||||
|
|
||||||
import auth_service.domain.login.controller.dtos.LoginRequest;
|
import auth_service.domain.login.controller.dtos.request.LoginRequest;
|
||||||
import auth_service.domain.login.controller.dtos.LoginResponse;
|
import auth_service.domain.login.controller.dtos.response.LoginResponse;
|
||||||
import auth_service.domain.login.repository.UserRepository;
|
import auth_service.domain.login.controller.mapper.ApiMapper;
|
||||||
|
import auth_service.domain.login.service.LoginService;
|
||||||
import io.micrometer.core.annotation.Timed;
|
import io.micrometer.core.annotation.Timed;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -21,8 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class LoginController {
|
public class LoginController {
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final LoginService loginService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates a user based on email and password.
|
* Authenticates a user based on email and password.
|
||||||
@@ -34,13 +32,8 @@ public class LoginController {
|
|||||||
@Timed(value = "auth.login.requests.time", description = "Time taken to process login requests")
|
@Timed(value = "auth.login.requests.time", description = "Time taken to process login requests")
|
||||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
|
||||||
|
|
||||||
return userRepository.findByEmail(request.email())
|
var user = ApiMapper.toModel(request);
|
||||||
.filter(user -> passwordEncoder.matches(request.password(), user.getPassword()))
|
loginService.login(user);
|
||||||
.map(user -> ResponseEntity.ok(
|
return ResponseEntity.ok(new LoginResponse("Login successful", user.getEmail()));
|
||||||
new LoginResponse("Login successful", user.getEmail())
|
|
||||||
))
|
|
||||||
.orElseGet(() -> ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(
|
|
||||||
new LoginResponse("Invalid credentials", null)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package auth_service.domain.login.controller;
|
||||||
|
|
||||||
|
import auth_service.domain.login.controller.dtos.response.ErrorResponse;
|
||||||
|
import auth_service.domain.login.exception.InvalidCredentialsException;
|
||||||
|
import auth_service.domain.login.exception.UserNotFoundException;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
@Order(value = 1)
|
||||||
|
@Slf4j
|
||||||
|
public class LoginExceptionController {
|
||||||
|
private static @NonNull ResponseEntity<ErrorResponse> response500(ErrorResponse body) {
|
||||||
|
log.error(body.message(), body.code());
|
||||||
|
return ResponseEntity.status(500)
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @NonNull ResponseEntity<ErrorResponse> response400(ErrorResponse body) {
|
||||||
|
return ResponseEntity.status(400)
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
######## CLIENT ERRORS (500-599)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
######## CLIENT ERRORS (400-499)
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(UserNotFoundException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(UserNotFoundException ex) {
|
||||||
|
var body = new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
|
||||||
|
return response400(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(InvalidCredentialsException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> clientError(InvalidCredentialsException ex) {
|
||||||
|
var body = new ErrorResponse("INVALID_CREDENTIALS", ex.getMessage());
|
||||||
|
return response400(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package auth_service.domain.login.controller.dtos;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO (Data Transfer Object) representing the incoming login request.
|
|
||||||
*/
|
|
||||||
public record LoginRequest(String email, String password) {
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package auth_service.domain.login.controller.dtos.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO (Data Transfer Object) representing the incoming login request.
|
||||||
|
*/
|
||||||
|
public record LoginRequest(@NotNull String email, @NotNull @NotBlank String password) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package auth_service.domain.login.controller.dtos.response;
|
||||||
|
|
||||||
|
public record ErrorResponse(String code, String message) {
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package auth_service.domain.login.controller.dtos;
|
package auth_service.domain.login.controller.dtos.response;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO representing the outgoing login response.
|
* DTO representing the outgoing login response.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.domain.login.controller.mapper;
|
||||||
|
|
||||||
|
import auth_service.domain.login.controller.dtos.request.LoginRequest;
|
||||||
|
import auth_service.domain.login.model.User;
|
||||||
|
|
||||||
|
public abstract class ApiMapper {
|
||||||
|
private ApiMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User toModel(LoginRequest loginRequest) {
|
||||||
|
return User.builder().email(loginRequest.email()).encryptedPassword(loginRequest.password()).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.domain.login.exception;
|
||||||
|
|
||||||
|
public class InvalidCredentialsException extends RuntimeException {
|
||||||
|
public InvalidCredentialsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package auth_service.domain.login.exception;
|
||||||
|
|
||||||
|
public class UserNotFoundException extends RuntimeException {
|
||||||
|
public UserNotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package auth_service.domain.login.model;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
@Data
|
||||||
|
public class User {
|
||||||
|
private long id;
|
||||||
|
private String email;
|
||||||
|
private String encryptedPassword;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package auth_service.domain.login.persistence;
|
||||||
|
|
||||||
|
import auth_service.domain.login.model.User;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for database operations regarding the User entity.
|
||||||
|
*/
|
||||||
|
public interface UserRepository {
|
||||||
|
/**
|
||||||
|
* Finds a user by their email address.
|
||||||
|
* * @param email The email to search for
|
||||||
|
*
|
||||||
|
* @return An Optional containing the user if found
|
||||||
|
*/
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package auth_service.domain.login.persistence;
|
||||||
|
|
||||||
|
import auth_service.domain.login.model.User;
|
||||||
|
import auth_service.domain.login.persistence.entities.UserEntity;
|
||||||
|
import auth_service.domain.login.persistence.mapper.EntityMapper;
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import jakarta.persistence.TypedQuery;
|
||||||
|
import jakarta.persistence.criteria.CriteriaBuilder;
|
||||||
|
import jakarta.persistence.criteria.CriteriaQuery;
|
||||||
|
import jakarta.persistence.criteria.Root;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class UserRepositoryImpl implements UserRepository {
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> findByEmail(String email) {
|
||||||
|
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
|
||||||
|
CriteriaQuery<UserEntity> query = cb.createQuery(UserEntity.class);
|
||||||
|
Root<UserEntity> user = query.from(UserEntity.class);
|
||||||
|
|
||||||
|
// Build the query: SELECT u FROM UserEntity u WHERE u.email = :email
|
||||||
|
query.select(user).where(cb.equal(user.get("email"), email));
|
||||||
|
|
||||||
|
TypedQuery<UserEntity> typedQuery = entityManager.createQuery(query);
|
||||||
|
|
||||||
|
return typedQuery.getResultStream()
|
||||||
|
.findFirst()
|
||||||
|
.map(EntityMapper::mapToModel);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package auth_service.domain.login.model;
|
package auth_service.domain.login.persistence.entities;
|
||||||
|
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package auth_service.domain.login.persistence.mapper;
|
||||||
|
|
||||||
|
import auth_service.domain.login.model.User;
|
||||||
|
import auth_service.domain.login.persistence.entities.UserEntity;
|
||||||
|
|
||||||
|
public abstract class EntityMapper {
|
||||||
|
|
||||||
|
private EntityMapper() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static User mapToModel(UserEntity entity) {
|
||||||
|
User model = User.builder().build();
|
||||||
|
|
||||||
|
if (entity != null) {
|
||||||
|
model.setId(entity.getId());
|
||||||
|
model.setEmail(entity.getEmail());
|
||||||
|
model.setEncryptedPassword(entity.getPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package auth_service.domain.login.repository;
|
|
||||||
|
|
||||||
import auth_service.domain.login.model.UserEntity;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
|
||||||
import org.springframework.stereotype.Repository;
|
|
||||||
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Repository for database operations regarding the User entity.
|
|
||||||
*/
|
|
||||||
@Repository
|
|
||||||
public interface UserRepository extends JpaRepository<UserEntity, Long> {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds a user by their email address.
|
|
||||||
* * @param email The email to search for
|
|
||||||
*
|
|
||||||
* @return An Optional containing the user if found
|
|
||||||
*/
|
|
||||||
Optional<UserEntity> findByEmail(String email);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package auth_service.domain.login.service;
|
||||||
|
|
||||||
|
import auth_service.domain.login.exception.InvalidCredentialsException;
|
||||||
|
import auth_service.domain.login.exception.UserNotFoundException;
|
||||||
|
import auth_service.domain.login.model.User;
|
||||||
|
import auth_service.domain.login.persistence.UserRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class LoginService {
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
public String login(User userToLogin) {
|
||||||
|
var userEntity = userRepository.findByEmail(userToLogin.getEmail()).orElseThrow(() -> new UserNotFoundException("User not found with given email"));
|
||||||
|
var loginSuccessful = passwordEncoder.matches(userToLogin.getEncryptedPassword(), userEntity.getEncryptedPassword());
|
||||||
|
if (!loginSuccessful) {
|
||||||
|
throw new InvalidCredentialsException("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user