diff --git a/README.md b/README.md index e3e7f793..30ed8e96 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,498 @@ -# Spring Boot 3.0 Security with JWT Implementation -This project demonstrates the implementation of security using Spring Boot 3.0 and JSON Web Tokens (JWT). It includes the following features: - -## Features -* User registration and login with JWT authentication -* Password encryption using BCrypt -* Role-based authorization with Spring Security -* Customized access denied handling -* Logout mechanism -* Refresh token - -## Technologies -* Spring Boot 3.0 -* Spring Security -* JSON Web Tokens (JWT) -* BCrypt -* Maven - -## Getting Started -To get started with this project, you will need to have the following installed on your local machine: - -* JDK 17+ -* Maven 3+ - - -To build and run the project, follow these steps: - -* Clone the repository: `git clone https://github.com/ali-bouali/spring-boot-3-jwt-security.git` -* Navigate to the project directory: cd spring-boot-security-jwt -* Add database "jwt_security" to postgres -* Build the project: mvn clean install -* Run the project: mvn spring-boot:run - --> The application will be available at http://localhost:8080. +# Spring Boot 3 JWT Security + +A production-ready Spring Boot 3 application implementing JWT-based authentication and authorization with role-based access control. + +## ๐Ÿš€ Features + +- **JWT Authentication**: Secure token-based authentication using JSON Web Tokens +- **User Registration & Login**: Complete user management with encrypted password storage +- **Access & Refresh Tokens**: Dual token system for enhanced security +- **Role-Based Authorization**: Fine-grained access control with custom permissions +- **Token Management**: Automatic token revocation on logout +- **Password Encryption**: BCrypt password hashing +- **JPA Auditing**: Automatic tracking of entity creation and modification +- **OpenAPI Documentation**: Interactive API documentation with Swagger UI +- **PostgreSQL Integration**: Production-grade database support +- **Docker Compose**: Easy setup with containerized PostgreSQL and pgAdmin + +## ๐Ÿ› ๏ธ Technologies + +| Technology | Version | Purpose | +|------------|---------|---------| +| Spring Boot | 3.3.2 | Application framework | +| Spring Security | 6.x | Authentication & authorization | +| Spring Data JPA | 3.x | Database access | +| JWT (jjwt) | 0.11.5 | Token generation & validation | +| PostgreSQL | Latest | Database | +| Lombok | Latest | Code generation | +| SpringDoc OpenAPI | 2.1.0 | API documentation | +| Maven | 3+ | Build tool | +| Java | 17+ | Programming language | + +## ๐Ÿ“‹ Prerequisites + +- **JDK 17** or higher +- **Maven 3.6+** +- **Docker** (optional, for running PostgreSQL) +- **PostgreSQL 14+** (if not using Docker) + +## ๐Ÿ”ง Installation & Setup + +### 1. Clone the Repository + +```bash +git clone https://github.com/ali-bouali/spring-boot-3-jwt-security.git +cd spring-boot-3-jwt-security +``` + +### 2. Start PostgreSQL (Docker) + +The easiest way to get started is using Docker Compose: + +```bash +docker-compose up -d +``` + +This will start: +- **PostgreSQL** on port `5432` +- **pgAdmin** on port `5050` (http://localhost:5050) + - Email: `pgadmin4@pgadmin.org` + - Password: `admin` + +### 3. Configure Application (Optional) + +The default configuration in `application.yml` should work out of the box with Docker Compose. If you need to customize: + +```yaml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/jwt_security + username: postgres + password: postgres + +application: + security: + jwt: + secret-key: your-secret-key-here + expiration: 86400000 # 24 hours + refresh-token: + expiration: 604800000 # 7 days +``` + +### 4. Build the Project + +```bash +mvn clean install +``` + +### 5. Run the Application + +```bash +mvn spring-boot:run +``` + +Or run the JAR directly: + +```bash +java -jar target/security-0.0.1-SNAPSHOT.jar +``` + +The application will start on **http://localhost:8080** + +## ๐Ÿ“š API Documentation + +Once the application is running, access the interactive API documentation: + +- **Swagger UI**: http://localhost:8080/swagger-ui.html +- **Alternative Swagger URL**: http://localhost:8080/swagger-ui/index.html +- **OpenAPI JSON**: http://localhost:8080/v3/api-docs + +> **Note**: Make sure the application is running and connected to the database before accessing Swagger UI. If you get a connection error, restart the application after starting the PostgreSQL container. + +## ๐Ÿ” API Endpoints + +### Authentication Endpoints + +| Method | Endpoint | Description | Auth Required | +|--------|----------|-------------|---------------| +| POST | `/api/v1/auth/register` | Register new user | No | +| POST | `/api/v1/auth/authenticate` | Login and get tokens | No | +| POST | `/api/v1/auth/refresh-token` | Get new access token | Yes (Refresh Token) | +| POST | `/api/v1/auth/logout` | Logout and revoke tokens | Yes | + +### User Management + +| Method | Endpoint | Description | Auth Required | +|--------|-----------------------------|-------------|---------------| +| PATCH | `/api/v1/users/me/password` | Change user password | Yes | + +### Book Management (Sample Resource) + +| Method | Endpoint | Description | Required Role | +|--------|----------|-------------|---------------| +| GET | `/api/v1/books` | Get all books | Any authenticated user | +| POST | `/api/v1/books` | Create new book | Any authenticated user | + +### Demo Endpoints (Role Testing) + +| Method | Endpoint | Description | Required Role | +|--------|------------------------|-------------|---------------| +| GET | `/api/v1/demo` | Public endpoint | Any authenticated user | +| GET | `/api/v1/admin/**` | Admin endpoints | ADMIN | +| GET | `/api/v1/management/**` | Management endpoints | MANAGER | + +## ๐Ÿ”‘ Usage Examples + +### 1. Register a New User + +```bash +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "firstname": "John", + "lastname": "Doe", + "email": "john.doe@example.com", + "password": "password123", + "role": "USER" + }' +``` + +**Response:** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." +} +``` + +### 2. Authenticate (Login) + +```bash +curl -X POST http://localhost:8080/api/v1/auth/authenticate \ + -H "Content-Type: application/json" \ + -d '{ + "email": "john.doe@example.com", + "password": "password123" + }' +``` + +**Response:** +```json +{ + "accessToken": "eyJhbGciOiJIUzI1NiJ9...", + "refreshToken": "eyJhbGciOiJIUzI1NiJ9..." +} +``` + +### 3. Access Protected Endpoint (Get All Books) + +```bash +curl -X GET http://localhost:8080/api/v1/books \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." +``` + +### 4. Create a New Book + +```bash +curl -X POST http://localhost:8080/api/v1/books \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \ + -H "Content-Type: application/json" \ + -d '{ + "author": "J.K. Rowling", + "isbn": "978-0-7475-3269-9" + }' +``` + +### 5. Refresh Access Token + +```bash +curl -X POST http://localhost:8080/api/v1/auth/refresh-token \ + -H "Authorization: Bearer " +``` + +### 6. Change Password for current authenticated User + +```bash +curl -X PATCH http://localhost:8080/api/v1/users/me/password \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "currentPassword": "password123", + "newPassword": "newPassword456", + "confirmationPassword": "newPassword456" + }' +``` + +## ๐Ÿ”’ Security Implementation + +### JWT Token Structure + +The application uses two types of tokens: + +1. **Access Token** (24 hours validity) + - Used for API authentication + - Contains user details and authorities + - Short-lived for security + +2. **Refresh Token** (7 days validity) + - Used to obtain new access tokens + - Stored in database for revocation + - Longer-lived for better UX + +### Token Management + +- All valid tokens are stored in the database +- On logout, all user tokens are revoked +- On new login, previous tokens are invalidated +- Tokens are checked against the database on each request + +### Role-Based Access Control + +The application supports hierarchical roles with specific permissions: + +**Roles:** +- `USER`: Basic user access +- `ADMIN`: Full system access +- `MANAGER`: Management-level access + +**Permissions:** +- `ADMIN_READ`, `ADMIN_UPDATE`, `ADMIN_CREATE`, `ADMIN_DELETE` +- `MANAGER_READ`, `MANAGER_UPDATE`, `MANAGER_CREATE`, `MANAGER_DELETE` + +## ๐ŸŽญ Managing User Authorities + +### How Roles and Authorities Work + +The application uses a flexible permission system where **roles contain sets of permissions**. Each user is assigned a role, and that role determines what authorities (permissions) they have. + +### Authority Structure + +When a user logs in, their role is converted into a list of authorities: + +| Role | Authorities Granted | +|------|-------------------| +| **USER** | `ROLE_USER` + custom permissions (if any) | +| **MANAGER** | `ROLE_MANAGER` + `management:create`, `management:read`, `management:update`, `management:delete` | +| **ADMIN** | `ROLE_ADMIN` + all admin permissions + all management permissions | + +### Adding/Removing Permissions from Roles + +To customize what permissions each role has, edit the `Role.java` enum: + +**Location:** `src/main/java/com/omore/security/user/Role.java` + +```java +@Getter +@RequiredArgsConstructor +public enum Role { + + USER( + Set.of( + // Add permissions here for USER role + // Example: ADMIN_CREATE, MANAGER_READ, etc. + ) + ), + + ADMIN( + Set.of( + ADMIN_CREATE, + ADMIN_READ, + ADMIN_UPDATE, + ADMIN_DELETE, + MANAGER_CREATE, // ADMIN also has MANAGER permissions + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) + ), + + MANAGER( + Set.of( + MANAGER_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) + ); + + private final Set permissions; +} +``` + +### Example: Give USER Role Admin Create Permission + +```java +USER( + Set.of( + ADMIN_CREATE // Now USER can create admin resources + ) +), +``` + +### Available Permissions + +All permissions are defined in `Permission.java`: + +```java +public enum Permission { + ADMIN_CREATE("admin:create"), + ADMIN_READ("admin:read"), + ADMIN_UPDATE("admin:update"), + ADMIN_DELETE("admin:delete"), + MANAGER_CREATE("management:create"), + MANAGER_READ("management:read"), + MANAGER_UPDATE("management:update"), + MANAGER_DELETE("management:delete"); +} +``` + +### Security Configuration + +โš ๏ธ **Important:** The `SecurityConfiguration.java` must use `.getPermission()` not `.name()`: + +```java +// โœ… CORRECT: +.requestMatchers(POST, "/api/v1/management/**") + .hasAnyAuthority(ADMIN_CREATE.getPermission(), MANAGER_CREATE.getPermission()) + +// OR use strings directly: +.requestMatchers(POST, "/api/v1/management/**") + .hasAnyAuthority("admin:create", "management:create") + +// โŒ WRONG (will not work): +.requestMatchers(POST, "/api/v1/management/**") + .hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) +``` + +### Role vs Authority Checks + +**Role-based checks** (checks for `ROLE_*`): +```java +@PreAuthorize("hasRole('ADMIN')") // Only users with ADMIN role +``` + +**Permission-based checks** (checks for specific permissions): +```java +@PreAuthorize("hasAuthority('admin:create')") // Anyone with admin:create permission +``` + +### Best Practices + +1. **Use permissions for fine-grained control**: Check for specific permissions like `admin:read` instead of roles +2. **Keep USER role minimal**: Only add permissions if absolutely necessary +3. **ADMIN inherits MANAGER permissions**: This allows admins to access management endpoints +4. **Restart required**: Changes to `Role.java` require application restart +5. **Test after changes**: Always test permission changes with different user roles + +### Example Use Cases + +**Scenario 1: Give USER read-only access to management** +```java +USER(Set.of(MANAGER_READ)) +``` + +**Scenario 2: Create a custom limited role** +```java +READONLY_ADMIN( + Set.of( + ADMIN_READ, + MANAGER_READ + ) +) +``` + +**Scenario 3: Remove permissions from ADMIN** +```java +ADMIN( + Set.of( + ADMIN_CREATE, + ADMIN_READ, + // Removed: ADMIN_UPDATE, ADMIN_DELETE + MANAGER_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) +) +``` + +### Password Security + +- Passwords are hashed using BCrypt with strength 10 +- Plain text passwords are never stored +- Password validation on change includes current password verification + +## ๐Ÿ—๏ธ Architecture Highlights + +### JWT Authentication Filter + +Custom filter (`JwtAuthenticationFilter`) intercepts all requests to: +1. Extract JWT from Authorization header +2. Validate token signature and expiration +3. Load user details from the database +4. Set authentication in Security Context + +### Database Schema + +**Main Tables:** +- `usr`: Stores user information and credentials +- `token`: Stores JWT tokens with revocation status +- `book`: Sample resource table with auditing fields (created_date, last_modified_date, created_by, last_modified_by) + +### Security Configuration + +- Stateless session management (no server-side sessions) +- JWT-based authentication +- Method-level security with `@PreAuthorize` +- CORS configuration +- Custom logout handling + +## ๐Ÿงช Testing + +Run tests with: + +```bash +mvn test +``` + +The project includes Spring Security Test support for testing secured endpoints. + +## ๐Ÿ› Troubleshooting + +### Database Connection Issues + +If you can't connect to PostgreSQL: +1. Verify Docker containers are running: `docker ps` +2. Check PostgreSQL logs: `docker logs postgres-sql` +3. Ensure port 5432 is not in use by another service + +### JWT Token Issues + +If tokens are not working: +1. Verify the `secret-key` in `application.yml` +2. Check token expiration times +3. Ensure the token is sent in the Authorization header as `Bearer ` +4. Check database for revoked tokens + +### Build Issues + +If Maven build fails: +1. Ensure JDK 17+ is installed: `java -version` +2. Clear Maven cache: `mvn clean` +3. Update dependencies: `mvn clean install -U` + +## ๐Ÿ“ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## ๐Ÿ‘ค Author + +Oleksii Morenets - [GitHub](https://github.com/o-morenets) + +## ๐Ÿค Contributing + +Contributions, issues, and feature requests are welcome! + +## โญ Show Your Support + +Give a โญ๏ธ if this project helped you! diff --git a/docker-compose.yml b/docker-compose.yml index 8c2973bf..8f4c5513 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,12 @@ services: + postgres: container_name: postgres-sql image: postgres environment: - POSTGRES_USER: username - POSTGRES_PASSWORD: password + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: jwt_security PGDATA: /data/postgres volumes: - postgres:/data/postgres diff --git a/http/change-password.http b/http/change-password.http deleted file mode 100644 index 66f47d2e..00000000 --- a/http/change-password.http +++ /dev/null @@ -1,45 +0,0 @@ -### Register User -POST http://localhost:8080/api/v1/auth/register -Content-Type: application/json - -{ - "firstname": "Ali", - "lastname": "Bouali", - "email": "alibou@mail.com", - "password": "password", - "role": "ADMIN" -} - -> {% client.global.set("auth-token", response.body.access_token); %} - -### Query the Demo endpoint -GET http://localhost:8080/api/v1/demo-controller -Authorization: Bearer {{auth-token}} - - -### Change the password -PATCH http://localhost:8080/api/v1/users -Content-Type: application/json -Authorization: Bearer {{auth-token}} - -{ - "currentPassword": "password", - "newPassword": "newPassword", - "confirmationPassword": "newPassword" -} - -### Login again and update the token -POST http://localhost:8080/api/v1/auth/authenticate -Content-Type: application/json - -{ - "email": "alibou@mail.com", - "password": "newPassword" -} - -> {% client.global.set("new-auth-token", response.body.access_token); %} - - -### Query the Demo endpoint after password change -GET http://localhost:8080/api/v1/demo-controller -Authorization: Bearer {{new-auth-token}} diff --git a/http/http-test.http b/http/http-test.http index 918cb2e4..7454b838 100644 --- a/http/http-test.http +++ b/http/http-test.http @@ -1,17 +1,18 @@ ### Register User POST http://localhost:8080/api/v1/auth/register Content-Type: application/json +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbkBtYWlsLmNvbSIsImlhdCI6MTc2MDk1OTAxMiwiZXhwIjoxNzYxMDQ1NDEyfQ.XnnTOk-XOyPXG0oKXJGJXqKhfV617HF9dkjgk7J2wq0 { - "firstname": "Ali", - "lastname": "Bouali", - "email": "alibou21@mail.com", - "password": "password", + "firstname": "Oleksii", + "lastname": "Morenets", + "email": "omore@mail.com", + "password": "password$123", "role": "ADMIN" } -> {% client.global.set("auth-token", response.body.access_token); %} +> {% client.global.set("access-token", response.body.access_token); %} ### Query the Demo endpoint -GET http://localhost:8080/api/v1/demo-controller -Authorization: Bearer {{auth-token}} +GET http://localhost:8080/api/v1/demo +Authorization: Bearer {{access-token}} diff --git a/http/jpa-auditing.http b/http/jpa-auditing.http deleted file mode 100644 index a8245bb8..00000000 --- a/http/jpa-auditing.http +++ /dev/null @@ -1,44 +0,0 @@ -### Register User -POST http://localhost:8080/api/v1/auth/register -Content-Type: application/json - -{ - "firstname": "Ali", - "lastname": "Bouali", - "email": "alibou@mail.com", - "password": "password", - "role": "ADMIN" -} - -> {% client.global.set("auth-token", response.body.access_token); %} - - -###Create a new book -POST http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} -Content-Type: application/json - -{ - "author": "Alibou", - "isbn": "12345" -} - -### Query Books -GET http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} - -### Update one book -POST http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} -Content-Type: application/json - -{ - "id": 1, - "author": "Alibou 2", - "isbn": "12345" -} - - -### Query the Books one more time -GET http://localhost:8080/api/v1/books -Authorization: Bearer {{auth-token}} diff --git a/pom.xml b/pom.xml index 9c14b261..4f45c65c 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.4 + 3.3.2 com.alibou diff --git a/src/main/java/com/alibou/security/SecurityApplication.java b/src/main/java/com/alibou/security/SecurityApplication.java deleted file mode 100644 index 1448cff4..00000000 --- a/src/main/java/com/alibou/security/SecurityApplication.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.alibou.security; - -import com.alibou.security.auth.AuthenticationService; -import com.alibou.security.auth.RegisterRequest; -import com.alibou.security.user.Role; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.context.annotation.Bean; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; - -import static com.alibou.security.user.Role.ADMIN; -import static com.alibou.security.user.Role.MANAGER; - -@SpringBootApplication -@EnableJpaAuditing(auditorAwareRef = "auditorAware") -public class SecurityApplication { - - public static void main(String[] args) { - SpringApplication.run(SecurityApplication.class, args); - } - - @Bean - public CommandLineRunner commandLineRunner( - AuthenticationService service - ) { - return args -> { - var admin = RegisterRequest.builder() - .firstname("Admin") - .lastname("Admin") - .email("admin@mail.com") - .password("password") - .role(ADMIN) - .build(); - System.out.println("Admin token: " + service.register(admin).getAccessToken()); - - var manager = RegisterRequest.builder() - .firstname("Admin") - .lastname("Admin") - .email("manager@mail.com") - .password("password") - .role(MANAGER) - .build(); - System.out.println("Manager token: " + service.register(manager).getAccessToken()); - - }; - } -} diff --git a/src/main/java/com/alibou/security/auth/AuthenticationController.java b/src/main/java/com/alibou/security/auth/AuthenticationController.java deleted file mode 100644 index e1d5107c..00000000 --- a/src/main/java/com/alibou/security/auth/AuthenticationController.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.alibou.security.auth; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -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 java.io.IOException; - -@RestController -@RequestMapping("/api/v1/auth") -@RequiredArgsConstructor -public class AuthenticationController { - - private final AuthenticationService service; - - @PostMapping("/register") - public ResponseEntity register( - @RequestBody RegisterRequest request - ) { - return ResponseEntity.ok(service.register(request)); - } - @PostMapping("/authenticate") - public ResponseEntity authenticate( - @RequestBody AuthenticationRequest request - ) { - return ResponseEntity.ok(service.authenticate(request)); - } - - @PostMapping("/refresh-token") - public void refreshToken( - HttpServletRequest request, - HttpServletResponse response - ) throws IOException { - service.refreshToken(request, response); - } - - -} diff --git a/src/main/java/com/alibou/security/auth/AuthenticationService.java b/src/main/java/com/alibou/security/auth/AuthenticationService.java deleted file mode 100644 index 53193a72..00000000 --- a/src/main/java/com/alibou/security/auth/AuthenticationService.java +++ /dev/null @@ -1,120 +0,0 @@ -package com.alibou.security.auth; - -import com.alibou.security.config.JwtService; -import com.alibou.security.token.Token; -import com.alibou.security.token.TokenRepository; -import com.alibou.security.token.TokenType; -import com.alibou.security.user.Role; -import com.alibou.security.user.User; -import com.alibou.security.user.UserRepository; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Service; - -import java.io.IOException; - -@Service -@RequiredArgsConstructor -public class AuthenticationService { - private final UserRepository repository; - private final TokenRepository tokenRepository; - private final PasswordEncoder passwordEncoder; - private final JwtService jwtService; - private final AuthenticationManager authenticationManager; - - public AuthenticationResponse register(RegisterRequest request) { - var user = User.builder() - .firstname(request.getFirstname()) - .lastname(request.getLastname()) - .email(request.getEmail()) - .password(passwordEncoder.encode(request.getPassword())) - .role(request.getRole()) - .build(); - var savedUser = repository.save(user); - var jwtToken = jwtService.generateToken(user); - var refreshToken = jwtService.generateRefreshToken(user); - saveUserToken(savedUser, jwtToken); - return AuthenticationResponse.builder() - .accessToken(jwtToken) - .refreshToken(refreshToken) - .build(); - } - - public AuthenticationResponse authenticate(AuthenticationRequest request) { - authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken( - request.getEmail(), - request.getPassword() - ) - ); - var user = repository.findByEmail(request.getEmail()) - .orElseThrow(); - var jwtToken = jwtService.generateToken(user); - var refreshToken = jwtService.generateRefreshToken(user); - revokeAllUserTokens(user); - saveUserToken(user, jwtToken); - return AuthenticationResponse.builder() - .accessToken(jwtToken) - .refreshToken(refreshToken) - .build(); - } - - private void saveUserToken(User user, String jwtToken) { - var token = Token.builder() - .user(user) - .token(jwtToken) - .tokenType(TokenType.BEARER) - .expired(false) - .revoked(false) - .build(); - tokenRepository.save(token); - } - - private void revokeAllUserTokens(User user) { - var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId()); - if (validUserTokens.isEmpty()) - return; - validUserTokens.forEach(token -> { - token.setExpired(true); - token.setRevoked(true); - }); - tokenRepository.saveAll(validUserTokens); - } - - public void refreshToken( - HttpServletRequest request, - HttpServletResponse response - ) throws IOException { - final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - final String refreshToken; - final String userEmail; - if (authHeader == null ||!authHeader.startsWith("Bearer ")) { - return; - } - refreshToken = authHeader.substring(7); - userEmail = jwtService.extractUsername(refreshToken); - if (userEmail != null) { - var user = this.repository.findByEmail(userEmail) - .orElseThrow(); - if (jwtService.isTokenValid(refreshToken, user)) { - var accessToken = jwtService.generateToken(user); - revokeAllUserTokens(user); - saveUserToken(user, accessToken); - var authResponse = AuthenticationResponse.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - new ObjectMapper().writeValue(response.getOutputStream(), authResponse); - } - } - } -} diff --git a/src/main/java/com/alibou/security/auth/RegisterRequest.java b/src/main/java/com/alibou/security/auth/RegisterRequest.java deleted file mode 100644 index 4f51665b..00000000 --- a/src/main/java/com/alibou/security/auth/RegisterRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.alibou.security.auth; - -import com.alibou.security.user.Role; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class RegisterRequest { - - private String firstname; - private String lastname; - private String email; - private String password; - private Role role; -} diff --git a/src/main/java/com/alibou/security/book/BookController.java b/src/main/java/com/alibou/security/book/BookController.java deleted file mode 100644 index 4c457280..00000000 --- a/src/main/java/com/alibou/security/book/BookController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.alibou.security.book; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -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 java.util.List; - -@RestController -@RequestMapping("/api/v1/books") -@RequiredArgsConstructor -public class BookController { - - private final BookService service; - - @PostMapping - public ResponseEntity save( - @RequestBody BookRequest request - ) { - service.save(request); - return ResponseEntity.accepted().build(); - } - - @GetMapping - public ResponseEntity> findAllBooks() { - return ResponseEntity.ok(service.findAll()); - } -} diff --git a/src/main/java/com/alibou/security/config/ApplicationConfig.java b/src/main/java/com/alibou/security/config/ApplicationConfig.java deleted file mode 100644 index ae71abf5..00000000 --- a/src/main/java/com/alibou/security/config/ApplicationConfig.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.alibou.security.config; - -import com.alibou.security.auditing.ApplicationAuditAware; -import com.alibou.security.user.UserRepository; -import jakarta.persistence.criteria.CriteriaBuilder; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.domain.AuditorAware; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.AuthenticationProvider; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.core.userdetails.UsernameNotFoundException; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; - -@Configuration -@RequiredArgsConstructor -public class ApplicationConfig { - - private final UserRepository repository; - - @Bean - public UserDetailsService userDetailsService() { - return username -> repository.findByEmail(username) - .orElseThrow(() -> new UsernameNotFoundException("User not found")); - } - - @Bean - public AuthenticationProvider authenticationProvider() { - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); - authProvider.setUserDetailsService(userDetailsService()); - authProvider.setPasswordEncoder(passwordEncoder()); - return authProvider; - } - - @Bean - public AuditorAware auditorAware() { - return new ApplicationAuditAware(); - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { - return config.getAuthenticationManager(); - } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } - -} diff --git a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java b/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java deleted file mode 100644 index d6e55d18..00000000 --- a/src/main/java/com/alibou/security/config/JwtAuthenticationFilter.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.alibou.security.config; - -import com.alibou.security.token.TokenRepository; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -import java.beans.Transient; -import java.io.IOException; -import java.security.Security; - -import jakarta.transaction.TransactionScoped; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.lang.NonNull; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final JwtService jwtService; - private final UserDetailsService userDetailsService; - private final TokenRepository tokenRepository; - - @Override - protected void doFilterInternal( - @NonNull HttpServletRequest request, - @NonNull HttpServletResponse response, - @NonNull FilterChain filterChain - ) throws ServletException, IOException { - if (request.getServletPath().contains("/api/v1/auth")) { - filterChain.doFilter(request, response); - return; - } - final String authHeader = request.getHeader("Authorization"); - final String jwt; - final String userEmail; - if (authHeader == null ||!authHeader.startsWith("Bearer ")) { - filterChain.doFilter(request, response); - return; - } - jwt = authHeader.substring(7); - userEmail = jwtService.extractUsername(jwt); - if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { - UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); - var isTokenValid = tokenRepository.findByToken(jwt) - .map(t -> !t.isExpired() && !t.isRevoked()) - .orElse(false); - if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { - UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( - userDetails, - null, - userDetails.getAuthorities() - ); - authToken.setDetails( - new WebAuthenticationDetailsSource().buildDetails(request) - ); - SecurityContextHolder.getContext().setAuthentication(authToken); - } - } - filterChain.doFilter(request, response); - } -} diff --git a/src/main/java/com/alibou/security/config/JwtService.java b/src/main/java/com/alibou/security/config/JwtService.java deleted file mode 100644 index 9c1ed46f..00000000 --- a/src/main/java/com/alibou/security/config/JwtService.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.alibou.security.config; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.SignatureAlgorithm; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import java.security.Key; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import java.util.function.Function; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Service; - -@Service -public class JwtService { - - @Value("${application.security.jwt.secret-key}") - private String secretKey; - @Value("${application.security.jwt.expiration}") - private long jwtExpiration; - @Value("${application.security.jwt.refresh-token.expiration}") - private long refreshExpiration; - - public String extractUsername(String token) { - return extractClaim(token, Claims::getSubject); - } - - public T extractClaim(String token, Function claimsResolver) { - final Claims claims = extractAllClaims(token); - return claimsResolver.apply(claims); - } - - public String generateToken(UserDetails userDetails) { - return generateToken(new HashMap<>(), userDetails); - } - - public String generateToken( - Map extraClaims, - UserDetails userDetails - ) { - return buildToken(extraClaims, userDetails, jwtExpiration); - } - - public String generateRefreshToken( - UserDetails userDetails - ) { - return buildToken(new HashMap<>(), userDetails, refreshExpiration); - } - - private String buildToken( - Map extraClaims, - UserDetails userDetails, - long expiration - ) { - return Jwts - .builder() - .setClaims(extraClaims) - .setSubject(userDetails.getUsername()) - .setIssuedAt(new Date(System.currentTimeMillis())) - .setExpiration(new Date(System.currentTimeMillis() + expiration)) - .signWith(getSignInKey(), SignatureAlgorithm.HS256) - .compact(); - } - - public boolean isTokenValid(String token, UserDetails userDetails) { - final String username = extractUsername(token); - return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); - } - - private boolean isTokenExpired(String token) { - return extractExpiration(token).before(new Date()); - } - - private Date extractExpiration(String token) { - return extractClaim(token, Claims::getExpiration); - } - - private Claims extractAllClaims(String token) { - return Jwts - .parserBuilder() - .setSigningKey(getSignInKey()) - .build() - .parseClaimsJws(token) - .getBody(); - } - - private Key getSignInKey() { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - return Keys.hmacShaKeyFor(keyBytes); - } -} diff --git a/src/main/java/com/alibou/security/config/LogoutService.java b/src/main/java/com/alibou/security/config/LogoutService.java deleted file mode 100644 index 0784565f..00000000 --- a/src/main/java/com/alibou/security/config/LogoutService.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.alibou.security.config; - -import com.alibou.security.token.TokenRepository; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.authentication.logout.LogoutHandler; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class LogoutService implements LogoutHandler { - - private final TokenRepository tokenRepository; - - @Override - public void logout( - HttpServletRequest request, - HttpServletResponse response, - Authentication authentication - ) { - final String authHeader = request.getHeader("Authorization"); - final String jwt; - if (authHeader == null ||!authHeader.startsWith("Bearer ")) { - return; - } - jwt = authHeader.substring(7); - var storedToken = tokenRepository.findByToken(jwt) - .orElse(null); - if (storedToken != null) { - storedToken.setExpired(true); - storedToken.setRevoked(true); - tokenRepository.save(storedToken); - SecurityContextHolder.clearContext(); - } - } -} diff --git a/src/main/java/com/alibou/security/config/SecurityConfiguration.java b/src/main/java/com/alibou/security/config/SecurityConfiguration.java deleted file mode 100644 index e4aefe66..00000000 --- a/src/main/java/com/alibou/security/config/SecurityConfiguration.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.alibou.security.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationProvider; -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.core.context.SecurityContextHolder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.authentication.logout.LogoutHandler; - -import static com.alibou.security.user.Permission.ADMIN_CREATE; -import static com.alibou.security.user.Permission.ADMIN_DELETE; -import static com.alibou.security.user.Permission.ADMIN_READ; -import static com.alibou.security.user.Permission.ADMIN_UPDATE; -import static com.alibou.security.user.Permission.MANAGER_CREATE; -import static com.alibou.security.user.Permission.MANAGER_DELETE; -import static com.alibou.security.user.Permission.MANAGER_READ; -import static com.alibou.security.user.Permission.MANAGER_UPDATE; -import static com.alibou.security.user.Role.ADMIN; -import static com.alibou.security.user.Role.MANAGER; -import static org.springframework.http.HttpMethod.DELETE; -import static org.springframework.http.HttpMethod.GET; -import static org.springframework.http.HttpMethod.POST; -import static org.springframework.http.HttpMethod.PUT; -import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -@EnableMethodSecurity -public class SecurityConfiguration { - - private static final String[] WHITE_LIST_URL = {"/api/v1/auth/**", - "/v2/api-docs", - "/v3/api-docs", - "/v3/api-docs/**", - "/swagger-resources", - "/swagger-resources/**", - "/configuration/ui", - "/configuration/security", - "/swagger-ui/**", - "/webjars/**", - "/swagger-ui.html"}; - private final JwtAuthenticationFilter jwtAuthFilter; - private final AuthenticationProvider authenticationProvider; - private final LogoutHandler logoutHandler; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(req -> - req.requestMatchers(WHITE_LIST_URL) - .permitAll() - .requestMatchers("/api/v1/management/**").hasAnyRole(ADMIN.name(), MANAGER.name()) - .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.name(), MANAGER_READ.name()) - .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.name(), MANAGER_CREATE.name()) - .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.name(), MANAGER_UPDATE.name()) - .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.name(), MANAGER_DELETE.name()) - .anyRequest() - .authenticated() - ) - .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) - .authenticationProvider(authenticationProvider) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) - .logout(logout -> - logout.logoutUrl("/api/v1/auth/logout") - .addLogoutHandler(logoutHandler) - .logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()) - ) - ; - - return http.build(); - } -} diff --git a/src/main/java/com/alibou/security/demo/AdminController.java b/src/main/java/com/alibou/security/demo/AdminController.java deleted file mode 100644 index 18ede653..00000000 --- a/src/main/java/com/alibou/security/demo/AdminController.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.alibou.security.demo; - -import io.swagger.v3.oas.annotations.Hidden; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/admin") -@PreAuthorize("hasRole('ADMIN')") -public class AdminController { - - @GetMapping - @PreAuthorize("hasAuthority('admin:read')") - public String get() { - return "GET:: admin controller"; - } - @PostMapping - @PreAuthorize("hasAuthority('admin:create')") - @Hidden - public String post() { - return "POST:: admin controller"; - } - @PutMapping - @PreAuthorize("hasAuthority('admin:update')") - @Hidden - public String put() { - return "PUT:: admin controller"; - } - @DeleteMapping - @PreAuthorize("hasAuthority('admin:delete')") - @Hidden - public String delete() { - return "DELETE:: admin controller"; - } -} diff --git a/src/main/java/com/alibou/security/demo/ManagementController.java b/src/main/java/com/alibou/security/demo/ManagementController.java deleted file mode 100644 index a214a9bb..00000000 --- a/src/main/java/com/alibou/security/demo/ManagementController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.alibou.security.demo; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/management") -@Tag(name = "Management") -public class ManagementController { - - - @Operation( - description = "Get endpoint for manager", - summary = "This is a summary for management get endpoint", - responses = { - @ApiResponse( - description = "Success", - responseCode = "200" - ), - @ApiResponse( - description = "Unauthorized / Invalid Token", - responseCode = "403" - ) - } - - ) - @GetMapping - public String get() { - return "GET:: management controller"; - } - @PostMapping - public String post() { - return "POST:: management controller"; - } - @PutMapping - public String put() { - return "PUT:: management controller"; - } - @DeleteMapping - public String delete() { - return "DELETE:: management controller"; - } -} diff --git a/src/main/java/com/alibou/security/token/Token.java b/src/main/java/com/alibou/security/token/Token.java deleted file mode 100644 index 71f35718..00000000 --- a/src/main/java/com/alibou/security/token/Token.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.alibou.security.token; - -import com.alibou.security.user.User; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Entity -public class Token { - - @Id - @GeneratedValue - public Integer id; - - @Column(unique = true) - public String token; - - @Enumerated(EnumType.STRING) - public TokenType tokenType = TokenType.BEARER; - - public boolean revoked; - - public boolean expired; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - public User user; -} diff --git a/src/main/java/com/alibou/security/token/TokenRepository.java b/src/main/java/com/alibou/security/token/TokenRepository.java deleted file mode 100644 index 48235d87..00000000 --- a/src/main/java/com/alibou/security/token/TokenRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.alibou.security.token; - -import java.util.List; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -public interface TokenRepository extends JpaRepository { - - @Query(value = """ - select t from Token t inner join User u\s - on t.user.id = u.id\s - where u.id = :id and (t.expired = false or t.revoked = false)\s - """) - List findAllValidTokenByUser(Integer id); - - Optional findByToken(String token); -} diff --git a/src/main/java/com/alibou/security/token/TokenType.java b/src/main/java/com/alibou/security/token/TokenType.java deleted file mode 100644 index 82a8cff7..00000000 --- a/src/main/java/com/alibou/security/token/TokenType.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.alibou.security.token; - -public enum TokenType { - BEARER -} diff --git a/src/main/java/com/alibou/security/user/Role.java b/src/main/java/com/alibou/security/user/Role.java deleted file mode 100644 index 0ff9bd15..00000000 --- a/src/main/java/com/alibou/security/user/Role.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.alibou.security.user; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.authority.SimpleGrantedAuthority; - -import java.util.Collections; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import static com.alibou.security.user.Permission.ADMIN_CREATE; -import static com.alibou.security.user.Permission.ADMIN_DELETE; -import static com.alibou.security.user.Permission.ADMIN_READ; -import static com.alibou.security.user.Permission.ADMIN_UPDATE; -import static com.alibou.security.user.Permission.MANAGER_CREATE; -import static com.alibou.security.user.Permission.MANAGER_DELETE; -import static com.alibou.security.user.Permission.MANAGER_READ; -import static com.alibou.security.user.Permission.MANAGER_UPDATE; - -@RequiredArgsConstructor -public enum Role { - - USER(Collections.emptySet()), - ADMIN( - Set.of( - ADMIN_READ, - ADMIN_UPDATE, - ADMIN_DELETE, - ADMIN_CREATE, - MANAGER_READ, - MANAGER_UPDATE, - MANAGER_DELETE, - MANAGER_CREATE - ) - ), - MANAGER( - Set.of( - MANAGER_READ, - MANAGER_UPDATE, - MANAGER_DELETE, - MANAGER_CREATE - ) - ) - - ; - - @Getter - private final Set permissions; - - public List getAuthorities() { - var authorities = getPermissions() - .stream() - .map(permission -> new SimpleGrantedAuthority(permission.getPermission())) - .collect(Collectors.toList()); - authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name())); - return authorities; - } -} diff --git a/src/main/java/com/alibou/security/user/User.java b/src/main/java/com/alibou/security/user/User.java deleted file mode 100644 index bc4e0869..00000000 --- a/src/main/java/com/alibou/security/user/User.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.alibou.security.user; - -import com.alibou.security.token.Token; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; -import java.util.Collection; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@Entity -@Table(name = "_user") -public class User implements UserDetails { - - @Id - @GeneratedValue - private Integer id; - private String firstname; - private String lastname; - private String email; - private String password; - - @Enumerated(EnumType.STRING) - private Role role; - - @OneToMany(mappedBy = "user") - private List tokens; - - @Override - public Collection getAuthorities() { - return role.getAuthorities(); - } - - @Override - public String getPassword() { - return password; - } - - @Override - public String getUsername() { - 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/src/main/java/com/omore/security/SecurityApplication.java b/src/main/java/com/omore/security/SecurityApplication.java new file mode 100644 index 00000000..fcef76c0 --- /dev/null +++ b/src/main/java/com/omore/security/SecurityApplication.java @@ -0,0 +1,52 @@ +package com.omore.security; + +import com.omore.security.auth.AuthenticationService; +import com.omore.security.auth.RegisterRequest; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import static com.omore.security.user.Role.*; + +@SpringBootApplication +@EnableJpaAuditing(auditorAwareRef = "auditorAware") +public class SecurityApplication { + + public static void main(String[] args) { + SpringApplication.run(SecurityApplication.class, args); + } + + @Bean + public CommandLineRunner commandLineRunner(AuthenticationService service) { + return args -> { + var admin = RegisterRequest.builder() + .firstname("Admin") + .lastname("Admin") + .email("admin@mail.com") + .password("admin$123") + .role(ADMIN) + .build(); + System.out.println("Admin token: " + service.register(admin).getAccessToken()); + + var manager = RegisterRequest.builder() + .firstname("Manager") + .lastname("Manager") + .email("manager@mail.com") + .password("manager$123") + .role(MANAGER) + .build(); + System.out.println("Manager token: " + service.register(manager).getAccessToken()); + + var user = RegisterRequest.builder() + .firstname("User") + .lastname("User") + .email("user@mail.com") + .password("user$123") + .role(USER) + .build(); + System.out.println("User token: " + service.register(user).getAccessToken()); + }; + } +} diff --git a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java b/src/main/java/com/omore/security/auditing/ApplicationAuditAware.java similarity index 87% rename from src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java rename to src/main/java/com/omore/security/auditing/ApplicationAuditAware.java index 3f8172f6..9ec57567 100644 --- a/src/main/java/com/alibou/security/auditing/ApplicationAuditAware.java +++ b/src/main/java/com/omore/security/auditing/ApplicationAuditAware.java @@ -1,6 +1,6 @@ -package com.alibou.security.auditing; +package com.omore.security.auditing; -import com.alibou.security.user.User; +import com.omore.security.user.User; import org.springframework.data.domain.AuditorAware; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; @@ -9,6 +9,7 @@ import java.util.Optional; public class ApplicationAuditAware implements AuditorAware { + @Override public Optional getCurrentAuditor() { Authentication authentication = @@ -16,13 +17,14 @@ public Optional getCurrentAuditor() { .getContext() .getAuthentication(); if (authentication == null || - !authentication.isAuthenticated() || + !authentication.isAuthenticated() || authentication instanceof AnonymousAuthenticationToken ) { return Optional.empty(); } User userPrincipal = (User) authentication.getPrincipal(); + return Optional.ofNullable(userPrincipal.getId()); } } diff --git a/src/main/java/com/omore/security/auth/AuthenticationController.java b/src/main/java/com/omore/security/auth/AuthenticationController.java new file mode 100644 index 00000000..3e2b42e2 --- /dev/null +++ b/src/main/java/com/omore/security/auth/AuthenticationController.java @@ -0,0 +1,84 @@ +package com.omore.security.auth; + +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.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication") +public class AuthenticationController { + + private final AuthenticationService authenticationService; + + @Operation( + summary = "Register new user (Admin)", + description = "Permissions: `ADMIN_ROLE`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PostMapping("/register") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity register(@RequestBody RegisterRequest request) { + return ResponseEntity.ok(authenticationService.register(request)); + } + + @Operation( + summary = "Authentication", + description = "Permissions: `any`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + } + ) + @PostMapping("/authenticate") + public ResponseEntity authenticate(@RequestBody AuthenticationRequest request) { + return ResponseEntity.ok(authenticationService.authenticate(request)); + } + + @Operation( + summary = "Refresh token", + description = "Permissions: `any`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Invalid/missing Token", + responseCode = "400" + ), + } + ) + @PostMapping("/refresh-token") + public ResponseEntity refreshToken(HttpServletRequest request) { + return ResponseEntity.ok(authenticationService.refreshToken(request)); + } +} diff --git a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java b/src/main/java/com/omore/security/auth/AuthenticationRequest.java similarity index 70% rename from src/main/java/com/alibou/security/auth/AuthenticationRequest.java rename to src/main/java/com/omore/security/auth/AuthenticationRequest.java index 6d727224..d97dceb1 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationRequest.java +++ b/src/main/java/com/omore/security/auth/AuthenticationRequest.java @@ -1,4 +1,4 @@ -package com.alibou.security.auth; +package com.omore.security.auth; import lombok.AllArgsConstructor; import lombok.Builder; @@ -11,6 +11,6 @@ @NoArgsConstructor public class AuthenticationRequest { - private String email; - String password; + private String email; + private String password; } diff --git a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java b/src/main/java/com/omore/security/auth/AuthenticationResponse.java similarity index 60% rename from src/main/java/com/alibou/security/auth/AuthenticationResponse.java rename to src/main/java/com/omore/security/auth/AuthenticationResponse.java index c10bbb6e..b2135e03 100644 --- a/src/main/java/com/alibou/security/auth/AuthenticationResponse.java +++ b/src/main/java/com/omore/security/auth/AuthenticationResponse.java @@ -1,4 +1,4 @@ -package com.alibou.security.auth; +package com.omore.security.auth; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -12,8 +12,10 @@ @NoArgsConstructor public class AuthenticationResponse { - @JsonProperty("access_token") - private String accessToken; - @JsonProperty("refresh_token") - private String refreshToken; + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("refresh_token") + private String refreshToken; + } diff --git a/src/main/java/com/omore/security/auth/AuthenticationService.java b/src/main/java/com/omore/security/auth/AuthenticationService.java new file mode 100644 index 00000000..a98d7322 --- /dev/null +++ b/src/main/java/com/omore/security/auth/AuthenticationService.java @@ -0,0 +1,114 @@ +package com.omore.security.auth; + +import com.omore.security.config.JwtService; +import com.omore.security.token.Token; +import com.omore.security.token.TokenRepository; +import com.omore.security.token.TokenType; +import com.omore.security.user.User; +import com.omore.security.user.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final UserRepository repository; + private final TokenRepository tokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtService jwtService; + private final AuthenticationManager authenticationManager; + + public AuthenticationResponse register(RegisterRequest request) { + var user = User.builder() + .firstname(request.getFirstname()) + .lastname(request.getLastname()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .role(request.getRole()) + .build(); + var savedUser = repository.save(user); + var jwtToken = jwtService.generateToken(user); + var refreshToken = jwtService.generateRefreshToken(user); + saveUserToken(savedUser, jwtToken); + + return AuthenticationResponse.builder() + .accessToken(jwtToken) + .refreshToken(refreshToken) + .build(); + } + + public AuthenticationResponse authenticate(AuthenticationRequest request) { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getEmail(), + request.getPassword() + ) + ); + var user = repository.findByEmail(request.getEmail()).orElseThrow(); + var jwtToken = jwtService.generateToken(user); + var refreshToken = jwtService.generateRefreshToken(user); + revokeAllUserTokens(user); + saveUserToken(user, jwtToken); + + return AuthenticationResponse.builder() + .accessToken(jwtToken) + .refreshToken(refreshToken) + .build(); + } + + public AuthenticationResponse refreshToken(HttpServletRequest request) { + final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new IllegalStateException("Missing or invalid Authorization header"); + } + + final String refreshToken = authHeader.substring(7); + final String userEmail = jwtService.extractUsername(refreshToken); + if (userEmail == null) { + throw new IllegalStateException("Invalid token"); + } + + var user = this.repository.findByEmail(userEmail).orElseThrow(); + if (!jwtService.isTokenValid(refreshToken, user)) { + throw new IllegalStateException("Invalid or expired refresh token"); + } + + var accessToken = jwtService.generateToken(user); + revokeAllUserTokens(user); + saveUserToken(user, accessToken); + + return AuthenticationResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + private void saveUserToken(User user, String jwtToken) { + var token = Token.builder() + .user(user) + .token(jwtToken) + .tokenType(TokenType.BEARER) + .expired(false) + .revoked(false) + .build(); + tokenRepository.save(token); + } + + private void revokeAllUserTokens(User user) { + var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId()); + if (validUserTokens.isEmpty()) + return; + validUserTokens.forEach(token -> { + token.setExpired(true); + token.setRevoked(true); + }); + tokenRepository.saveAll(validUserTokens); + } +} diff --git a/src/main/java/com/omore/security/auth/RegisterRequest.java b/src/main/java/com/omore/security/auth/RegisterRequest.java new file mode 100644 index 00000000..66e3e887 --- /dev/null +++ b/src/main/java/com/omore/security/auth/RegisterRequest.java @@ -0,0 +1,20 @@ +package com.omore.security.auth; + +import com.omore.security.user.Role; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RegisterRequest { + + private String firstname; + private String lastname; + private String email; + private String password; + private Role role; +} diff --git a/src/main/java/com/alibou/security/book/Book.java b/src/main/java/com/omore/security/book/Book.java similarity index 71% rename from src/main/java/com/alibou/security/book/Book.java rename to src/main/java/com/omore/security/book/Book.java index 3f041afa..2b5b5dfc 100644 --- a/src/main/java/com/alibou/security/book/Book.java +++ b/src/main/java/com/omore/security/book/Book.java @@ -1,10 +1,6 @@ -package com.alibou.security.book; +package com.omore.security.book; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EntityListeners; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.Id; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,26 +24,21 @@ public class Book { @Id @GeneratedValue private Integer id; + private String author; + private String isbn; @CreatedDate - @Column( - nullable = false, - updatable = false - ) + @Column(nullable = false, updatable = false) private LocalDateTime createDate; @LastModifiedDate @Column(insertable = false) private LocalDateTime lastModified; - @CreatedBy - @Column( - nullable = false, - updatable = false - ) + @Column(nullable = false, updatable = false) private Integer createdBy; @LastModifiedBy diff --git a/src/main/java/com/omore/security/book/BookController.java b/src/main/java/com/omore/security/book/BookController.java new file mode 100644 index 00000000..7930650f --- /dev/null +++ b/src/main/java/com/omore/security/book/BookController.java @@ -0,0 +1,67 @@ +package com.omore.security.book; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/books") +@RequiredArgsConstructor +@Tag(name = "Books") +public class BookController { + + private final BookService service; + + @Operation( + summary = "POST endpoint for any authenticated user", + description = "Permissions: `any authenticated`", + responses = { + @ApiResponse( + description = "Accepted", + responseCode = "202" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PostMapping + public ResponseEntity save(@RequestBody BookRequest request) { + service.save(request); + + return ResponseEntity.accepted().build(); + } + + @Operation( + summary = "GET endpoint for any authenticated user", + description = "Permissions: `any authenticated`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @GetMapping + public ResponseEntity> findAllBooks() { + return ResponseEntity.ok(service.findAll()); + } +} diff --git a/src/main/java/com/alibou/security/book/BookRepository.java b/src/main/java/com/omore/security/book/BookRepository.java similarity index 80% rename from src/main/java/com/alibou/security/book/BookRepository.java rename to src/main/java/com/omore/security/book/BookRepository.java index 21ca467c..3c45fa82 100644 --- a/src/main/java/com/alibou/security/book/BookRepository.java +++ b/src/main/java/com/omore/security/book/BookRepository.java @@ -1,4 +1,4 @@ -package com.alibou.security.book; +package com.omore.security.book; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/alibou/security/book/BookRequest.java b/src/main/java/com/omore/security/book/BookRequest.java similarity index 58% rename from src/main/java/com/alibou/security/book/BookRequest.java rename to src/main/java/com/omore/security/book/BookRequest.java index dcf6765b..6202fc50 100644 --- a/src/main/java/com/alibou/security/book/BookRequest.java +++ b/src/main/java/com/omore/security/book/BookRequest.java @@ -1,12 +1,16 @@ -package com.alibou.security.book; +package com.omore.security.book; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @Builder +@NoArgsConstructor +@AllArgsConstructor public class BookRequest { private Integer id; diff --git a/src/main/java/com/alibou/security/book/BookService.java b/src/main/java/com/omore/security/book/BookService.java similarity index 94% rename from src/main/java/com/alibou/security/book/BookService.java rename to src/main/java/com/omore/security/book/BookService.java index c09ded8b..62374ac0 100644 --- a/src/main/java/com/alibou/security/book/BookService.java +++ b/src/main/java/com/omore/security/book/BookService.java @@ -1,4 +1,4 @@ -package com.alibou.security.book; +package com.omore.security.book; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/omore/security/config/ApplicationConfig.java b/src/main/java/com/omore/security/config/ApplicationConfig.java new file mode 100644 index 00000000..21927155 --- /dev/null +++ b/src/main/java/com/omore/security/config/ApplicationConfig.java @@ -0,0 +1,42 @@ +package com.omore.security.config; + +import com.omore.security.auditing.ApplicationAuditAware; +import com.omore.security.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.domain.AuditorAware; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +@RequiredArgsConstructor +public class ApplicationConfig { + + private final UserRepository repository; + + @Bean + public UserDetailsService userDetailsService() { + return username -> repository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found")); + } + + @Bean + public AuditorAware auditorAware() { + return new ApplicationAuditAware(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/omore/security/config/JacksonConfig.java b/src/main/java/com/omore/security/config/JacksonConfig.java new file mode 100644 index 00000000..775472f2 --- /dev/null +++ b/src/main/java/com/omore/security/config/JacksonConfig.java @@ -0,0 +1,20 @@ +package com.omore.security.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} + diff --git a/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java b/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java new file mode 100644 index 00000000..2d144d9b --- /dev/null +++ b/src/main/java/com/omore/security/config/JwtAuthenticationFilter.java @@ -0,0 +1,96 @@ +package com.omore.security.config; + +import com.omore.security.token.TokenRepository; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.lang.NonNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserDetailsService userDetailsService; + private final TokenRepository tokenRepository; + private final HandlerExceptionResolver exceptionResolver; + + public JwtAuthenticationFilter( + JwtService jwtService, + UserDetailsService userDetailsService, + TokenRepository tokenRepository, + @Qualifier("handlerExceptionResolver") HandlerExceptionResolver exceptionResolver + ) { + this.jwtService = jwtService; + this.userDetailsService = userDetailsService; + this.tokenRepository = tokenRepository; + this.exceptionResolver = exceptionResolver; + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + final String servletPath = request.getServletPath(); + + // Skip JWT processing for public endpoints + if (servletPath.equals("/api/v1/auth/authenticate") || + servletPath.equals("/api/v1/auth/refresh-token") || + servletPath.startsWith("/v3/api-docs") || + servletPath.startsWith("/swagger-ui") || + servletPath.equals("/swagger-ui.html")) { + filterChain.doFilter(request, response); + + return; + } + + final String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + try { + final String jwt = authHeader.substring(7); + final String userEmail = jwtService.extractUsername(jwt); + + if (userEmail != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(userEmail); + var isTokenValid = tokenRepository.findByToken(jwt) + .map(t -> !t.isExpired() && !t.isRevoked()) + .orElse(false); + + if (jwtService.isTokenValid(jwt, userDetails) && isTokenValid) { + UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + filterChain.doFilter(request, response); + } catch (ExpiredJwtException | UnsupportedJwtException | MalformedJwtException | + SignatureException | IllegalArgumentException ex) { + exceptionResolver.resolveException(request, response, null, ex); + } + } +} diff --git a/src/main/java/com/omore/security/config/JwtService.java b/src/main/java/com/omore/security/config/JwtService.java new file mode 100644 index 00000000..2ad84c50 --- /dev/null +++ b/src/main/java/com/omore/security/config/JwtService.java @@ -0,0 +1,91 @@ +package com.omore.security.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; + +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +@Service +public class JwtService { + + @Value("${application.security.jwt.secret-key}") + private String secretKey; + + @Value("${application.security.jwt.expiration}") + private long jwtExpiration; + + @Value("${application.security.jwt.refresh-token.expiration}") + private long refreshExpiration; + + public String extractUsername(String token) { + return extractClaim(token, Claims::getSubject); + } + + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + public String generateToken(UserDetails userDetails) { + return generateToken(new HashMap<>(), userDetails); + } + + public String generateToken(Map extraClaims, UserDetails userDetails) { + return buildToken(extraClaims, userDetails, jwtExpiration); + } + + public String generateRefreshToken(UserDetails userDetails) { + return buildToken(new HashMap<>(), userDetails, refreshExpiration); + } + + private String buildToken(Map extraClaims, UserDetails userDetails, long expiration) { + return Jwts + .builder() + .setClaims(extraClaims) + .setSubject(userDetails.getUsername()) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expiration)) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public boolean isTokenValid(String token, UserDetails userDetails) { + final String username = extractUsername(token); + + return (username.equals(userDetails.getUsername())) && !isTokenExpired(token); + } + + private boolean isTokenExpired(String token) { + return extractExpiration(token).before(new Date()); + } + + private Date extractExpiration(String token) { + return extractClaim(token, Claims::getExpiration); + } + + private Claims extractAllClaims(String token) { + return Jwts + .parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } + + private Key getSigningKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + + return Keys.hmacShaKeyFor(keyBytes); + } +} diff --git a/src/main/java/com/omore/security/config/LogoutService.java b/src/main/java/com/omore/security/config/LogoutService.java new file mode 100644 index 00000000..a3ff9351 --- /dev/null +++ b/src/main/java/com/omore/security/config/LogoutService.java @@ -0,0 +1,34 @@ +package com.omore.security.config; + +import com.omore.security.token.TokenRepository; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LogoutService implements LogoutHandler { + + private final TokenRepository tokenRepository; + + @Override + public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { + final String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return; + } + final String jwt = authHeader.substring(7); + var storedToken = tokenRepository.findByToken(jwt).orElse(null); + if (storedToken != null) { + storedToken.setExpired(true); + storedToken.setRevoked(true); + tokenRepository.save(storedToken); + SecurityContextHolder.clearContext(); + } + } +} diff --git a/src/main/java/com/alibou/security/config/OpenApiConfig.java b/src/main/java/com/omore/security/config/OpenApiConfig.java similarity index 57% rename from src/main/java/com/alibou/security/config/OpenApiConfig.java rename to src/main/java/com/omore/security/config/OpenApiConfig.java index 58d6929f..de2c27a2 100644 --- a/src/main/java/com/alibou/security/config/OpenApiConfig.java +++ b/src/main/java/com/omore/security/config/OpenApiConfig.java @@ -1,40 +1,24 @@ -package com.alibou.security.config; +package com.omore.security.config; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; -import io.swagger.v3.oas.annotations.info.Contact; import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.info.License; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.security.SecurityScheme; import io.swagger.v3.oas.annotations.servers.Server; @OpenAPIDefinition( info = @Info( - contact = @Contact( - name = "Alibou", - email = "contact@aliboucoding.com", - url = "https://aliboucoding.com/course" - ), + title = "OpenApi specification", description = "OpenApi documentation for Spring Security", - title = "OpenApi specification - Alibou", - version = "1.0", - license = @License( - name = "Licence name", - url = "https://some-url.com" - ), - termsOfService = "Terms of service" + version = "1.0" ), servers = { @Server( description = "Local ENV", url = "http://localhost:8080" ), - @Server( - description = "PROD ENV", - url = "https://aliboucoding.com/course" - ) }, security = { @SecurityRequirement( diff --git a/src/main/java/com/omore/security/config/SecurityConfiguration.java b/src/main/java/com/omore/security/config/SecurityConfiguration.java new file mode 100644 index 00000000..f50852ab --- /dev/null +++ b/src/main/java/com/omore/security/config/SecurityConfiguration.java @@ -0,0 +1,90 @@ +package com.omore.security.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.core.context.SecurityContextHolder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.logout.LogoutHandler; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import static com.omore.security.user.Permission.*; +import static org.springframework.http.HttpMethod.*; +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfiguration { + + private static final String[] WHITE_LIST_URL = { + "/api/v1/auth/authenticate", + "/api/v1/auth/refresh-token", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" + }; + + private final JwtAuthenticationFilter jwtAuthFilter; + private final LogoutHandler logoutHandler; + private final ObjectMapper objectMapper; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(req -> + req + .requestMatchers(WHITE_LIST_URL).permitAll() + .requestMatchers(POST, "/api/v1/management/**").hasAnyAuthority(ADMIN_CREATE.getPermission(), MANAGER_CREATE.getPermission()) + .requestMatchers(GET, "/api/v1/management/**").hasAnyAuthority(ADMIN_READ.getPermission(), MANAGER_READ.getPermission()) + .requestMatchers(PUT, "/api/v1/management/**").hasAnyAuthority(ADMIN_UPDATE.getPermission(), MANAGER_UPDATE.getPermission()) + .requestMatchers(DELETE, "/api/v1/management/**").hasAnyAuthority(ADMIN_DELETE.getPermission(), MANAGER_DELETE.getPermission()) + .anyRequest().authenticated() + ) + .sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception + .authenticationEntryPoint((request, response, authException) -> { + response.setStatus(401); + response.setContentType("application/json"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", 401); + errorResponse.put("error", "Unauthorized"); + errorResponse.put("message", authException.getMessage()); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + }) + .accessDeniedHandler((request, response, accessDeniedException) -> { + response.setStatus(403); + response.setContentType("application/json"); + + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", 403); + errorResponse.put("error", "Forbidden"); + errorResponse.put("message", "Access denied"); + + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + }) + ) + .logout(logout -> + logout.logoutUrl("/api/v1/auth/logout") + .addLogoutHandler(logoutHandler) + .logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext()) + ) + .build(); + } +} diff --git a/src/main/java/com/omore/security/demo/AdminController.java b/src/main/java/com/omore/security/demo/AdminController.java new file mode 100644 index 00000000..2f7f87b7 --- /dev/null +++ b/src/main/java/com/omore/security/demo/AdminController.java @@ -0,0 +1,110 @@ +package com.omore.security.demo; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin") +@PreAuthorize("hasRole('ADMIN')") +@Tag(name = "Admin") +public class AdminController { + + @Operation( + summary = "POST endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PostMapping + @PreAuthorize("hasAuthority('admin:create')") + public String post() { + return "POST:: admin controller"; + } + + @Operation( + summary = "GET endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @GetMapping + @PreAuthorize("hasAuthority('admin:read')") + public String get() { + return "GET:: admin controller"; + } + + @Operation( + summary = "PUT endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PutMapping + @PreAuthorize("hasAuthority('admin:update')") + public String put() { + return "PUT:: admin controller"; + } + + @Operation( + summary = "DELETE endpoint for admin", + description = "Permissions: `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @DeleteMapping + @PreAuthorize("hasAuthority('admin:delete')") + public String delete() { + return "DELETE:: admin controller"; + } +} diff --git a/src/main/java/com/alibou/security/demo/DemoController.java b/src/main/java/com/omore/security/demo/DemoController.java similarity index 50% rename from src/main/java/com/alibou/security/demo/DemoController.java rename to src/main/java/com/omore/security/demo/DemoController.java index ee2c380b..aa8a5814 100644 --- a/src/main/java/com/alibou/security/demo/DemoController.java +++ b/src/main/java/com/omore/security/demo/DemoController.java @@ -1,19 +1,18 @@ -package com.alibou.security.demo; +package com.omore.security.demo; -import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1/demo-controller") -@Hidden +@RequestMapping("/api/v1/demo") +@Tag(name = "Demo Controller") public class DemoController { - @GetMapping - public ResponseEntity sayHello() { - return ResponseEntity.ok("Hello from secured endpoint"); - } - + @GetMapping + public ResponseEntity sayHello() { + return ResponseEntity.ok("Hello from secured endpoint"); + } } diff --git a/src/main/java/com/omore/security/demo/ManagementController.java b/src/main/java/com/omore/security/demo/ManagementController.java new file mode 100644 index 00000000..13f95942 --- /dev/null +++ b/src/main/java/com/omore/security/demo/ManagementController.java @@ -0,0 +1,104 @@ +package com.omore.security.demo; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/management") +@Tag(name = "Management") +public class ManagementController { + + @Operation( + summary = "POST endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PostMapping + public String post() { + return "POST:: management controller"; + } + + @Operation( + summary = "GET endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @GetMapping + public String get() { + return "GET:: management controller"; + } + + @Operation( + summary = "PUT endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @PutMapping + public String put() { + return "PUT:: management controller"; + } + + @Operation( + summary = "DELETE endpoint for manager", + description = "Permissions: `manager:read` or `admin:read`", + responses = { + @ApiResponse( + description = "Success", + responseCode = "200" + ), + @ApiResponse( + description = "Unauthorized / Invalid Token", + responseCode = "401" + ), + @ApiResponse( + description = "Forbidden", + responseCode = "403" + ) + } + ) + @DeleteMapping + public String delete() { + return "DELETE:: management controller"; + } +} diff --git a/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java b/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..7571a9a1 --- /dev/null +++ b/src/main/java/com/omore/security/exception/GlobalExceptionHandler.java @@ -0,0 +1,78 @@ +package com.omore.security.exception; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.SignatureException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity> handleExpiredJwtException(ExpiredJwtException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(UnsupportedJwtException.class) + public ResponseEntity> handleUnsupportedJwtException(UnsupportedJwtException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(MalformedJwtException.class) + public ResponseEntity> handleMalformedJwtException(MalformedJwtException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(SignatureException.class) + public ResponseEntity> handleSignatureException(SignatureException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalStateException(IllegalStateException ex) { + return buildErrorResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> handleBadCredentialsException(BadCredentialsException ex) { + return buildErrorResponse(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { + return buildErrorResponse(HttpStatus.FORBIDDEN, ex.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneralException(Exception ex) { + return buildErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage()); + } + + private ResponseEntity> buildErrorResponse(HttpStatus status, String message) { + Map errorResponse = new HashMap<>(); + errorResponse.put("timestamp", LocalDateTime.now()); + errorResponse.put("status", status.value()); + errorResponse.put("error", status.getReasonPhrase()); + errorResponse.put("message", message); + + return ResponseEntity + .status(status) + .body(errorResponse); + } +} + diff --git a/src/main/java/com/omore/security/token/Token.java b/src/main/java/com/omore/security/token/Token.java new file mode 100644 index 00000000..fbf5ca4d --- /dev/null +++ b/src/main/java/com/omore/security/token/Token.java @@ -0,0 +1,34 @@ +package com.omore.security.token; + +import com.omore.security.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +public class Token { + + @Id + @GeneratedValue + public Integer id; + + @Column(unique = true) + public String token; + + @Enumerated(EnumType.STRING) + public TokenType tokenType = TokenType.BEARER; + + public boolean revoked; + + public boolean expired; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + public User user; +} diff --git a/src/main/java/com/omore/security/token/TokenRepository.java b/src/main/java/com/omore/security/token/TokenRepository.java new file mode 100644 index 00000000..3100db3d --- /dev/null +++ b/src/main/java/com/omore/security/token/TokenRepository.java @@ -0,0 +1,19 @@ +package com.omore.security.token; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface TokenRepository extends JpaRepository { + + @Query(value = """ + select t from Token t inner join User u\s + on t.user.id = u.id\s + where u.id = :id and (t.expired = false or t.revoked = false) + """) + List findAllValidTokenByUser(Integer id); + + Optional findByToken(String token); +} diff --git a/src/main/java/com/omore/security/token/TokenType.java b/src/main/java/com/omore/security/token/TokenType.java new file mode 100644 index 00000000..c9e899c1 --- /dev/null +++ b/src/main/java/com/omore/security/token/TokenType.java @@ -0,0 +1,5 @@ +package com.omore.security.token; + +public enum TokenType { + BEARER +} diff --git a/src/main/java/com/alibou/security/user/ChangePasswordRequest.java b/src/main/java/com/omore/security/user/ChangePasswordRequest.java similarity index 63% rename from src/main/java/com/alibou/security/user/ChangePasswordRequest.java rename to src/main/java/com/omore/security/user/ChangePasswordRequest.java index 70bca36b..9d42fa5a 100644 --- a/src/main/java/com/alibou/security/user/ChangePasswordRequest.java +++ b/src/main/java/com/omore/security/user/ChangePasswordRequest.java @@ -1,12 +1,16 @@ -package com.alibou.security.user; +package com.omore.security.user; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; @Getter @Setter @Builder +@NoArgsConstructor +@AllArgsConstructor public class ChangePasswordRequest { private String currentPassword; diff --git a/src/main/java/com/alibou/security/user/Permission.java b/src/main/java/com/omore/security/user/Permission.java similarity index 81% rename from src/main/java/com/alibou/security/user/Permission.java rename to src/main/java/com/omore/security/user/Permission.java index 16ae8b4c..8cf838b2 100644 --- a/src/main/java/com/alibou/security/user/Permission.java +++ b/src/main/java/com/omore/security/user/Permission.java @@ -1,22 +1,20 @@ -package com.alibou.security.user; +package com.omore.security.user; import lombok.Getter; import lombok.RequiredArgsConstructor; +@Getter @RequiredArgsConstructor public enum Permission { + ADMIN_CREATE("admin:create"), ADMIN_READ("admin:read"), ADMIN_UPDATE("admin:update"), - ADMIN_CREATE("admin:create"), ADMIN_DELETE("admin:delete"), + MANAGER_CREATE("management:create"), MANAGER_READ("management:read"), MANAGER_UPDATE("management:update"), - MANAGER_CREATE("management:create"), - MANAGER_DELETE("management:delete") - - ; + MANAGER_DELETE("management:delete"); - @Getter private final String permission; } diff --git a/src/main/java/com/omore/security/user/Role.java b/src/main/java/com/omore/security/user/Role.java new file mode 100644 index 00000000..c95a712d --- /dev/null +++ b/src/main/java/com/omore/security/user/Role.java @@ -0,0 +1,64 @@ +package com.omore.security.user; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static com.omore.security.user.Permission.*; + +@Getter +@RequiredArgsConstructor +public enum Role { + + USER( + Set.of() + ), + ADMIN( + Set.of( + ADMIN_CREATE, + ADMIN_READ, + ADMIN_UPDATE, + ADMIN_DELETE, + MANAGER_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) + ), + MANAGER( + Set.of( + MANAGER_CREATE, + MANAGER_READ, + MANAGER_UPDATE, + MANAGER_DELETE + ) + ); + + private final Set permissions; + + /** + * Returns a list of authorities for this role. + * + * @return Example for MANAGER: + * [ + * "management:create", + * "management:read", + * "management:update", + * "management:delete", + * "ROLE_MANAGER" + * ] + */ + public List getAuthorities() { + var authorities = getPermissions() + .stream() + .map(permission -> new SimpleGrantedAuthority(permission.getPermission())) + .collect(Collectors.toList()); + authorities.add(new SimpleGrantedAuthority("ROLE_" + this.name())); + + return authorities; + } +} diff --git a/src/main/java/com/omore/security/user/User.java b/src/main/java/com/omore/security/user/User.java new file mode 100644 index 00000000..de09a948 --- /dev/null +++ b/src/main/java/com/omore/security/user/User.java @@ -0,0 +1,55 @@ +package com.omore.security.user; + +import com.omore.security.token.Token; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Entity +@Table(name = "usr") +public class User implements UserDetails { + + @Id + @GeneratedValue + private Integer id; + + private String firstname; + + private String lastname; + + private String email; + + private String password; + + @Enumerated(EnumType.STRING) + private Role role; + + @OneToMany(mappedBy = "user") + private List tokens; + + @Override + public Collection getAuthorities() { + return role.getAuthorities(); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return email; + } +} diff --git a/src/main/java/com/alibou/security/user/UserController.java b/src/main/java/com/omore/security/user/UserController.java similarity index 59% rename from src/main/java/com/alibou/security/user/UserController.java rename to src/main/java/com/omore/security/user/UserController.java index 415be48e..640c5178 100644 --- a/src/main/java/com/alibou/security/user/UserController.java +++ b/src/main/java/com/omore/security/user/UserController.java @@ -1,5 +1,6 @@ -package com.alibou.security.user; +package com.omore.security.user; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PatchMapping; @@ -12,16 +13,15 @@ @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor +@Tag(name = "Users") public class UserController { - private final UserService service; + private final UserService userService; + + @PatchMapping("/me/password") + public ResponseEntity changePassword(@RequestBody ChangePasswordRequest request, Principal connectedUser) { + userService.changePassword(request, connectedUser); - @PatchMapping - public ResponseEntity changePassword( - @RequestBody ChangePasswordRequest request, - Principal connectedUser - ) { - service.changePassword(request, connectedUser); return ResponseEntity.ok().build(); } } diff --git a/src/main/java/com/alibou/security/user/UserRepository.java b/src/main/java/com/omore/security/user/UserRepository.java similarity index 67% rename from src/main/java/com/alibou/security/user/UserRepository.java rename to src/main/java/com/omore/security/user/UserRepository.java index a979ad61..acf1b2b6 100644 --- a/src/main/java/com/alibou/security/user/UserRepository.java +++ b/src/main/java/com/omore/security/user/UserRepository.java @@ -1,10 +1,10 @@ -package com.alibou.security.user; +package com.omore.security.user; -import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface UserRepository extends JpaRepository { +import java.util.Optional; - Optional findByEmail(String email); +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); } diff --git a/src/main/java/com/alibou/security/user/UserService.java b/src/main/java/com/omore/security/user/UserService.java similarity index 97% rename from src/main/java/com/alibou/security/user/UserService.java rename to src/main/java/com/omore/security/user/UserService.java index a17181d0..0dff16fb 100644 --- a/src/main/java/com/alibou/security/user/UserService.java +++ b/src/main/java/com/omore/security/user/UserService.java @@ -1,4 +1,4 @@ -package com.alibou.security.user; +package com.omore.security.user; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -13,6 +13,7 @@ public class UserService { private final PasswordEncoder passwordEncoder; private final UserRepository repository; + public void changePassword(ChangePasswordRequest request, Principal connectedUser) { var user = (User) ((UsernamePasswordAuthenticationToken) connectedUser).getPrincipal(); @@ -21,6 +22,7 @@ public void changePassword(ChangePasswordRequest request, Principal connectedUse if (!passwordEncoder.matches(request.getCurrentPassword(), user.getPassword())) { throw new IllegalStateException("Wrong password"); } + // check if the two new passwords are the same if (!request.getNewPassword().equals(request.getConfirmationPassword())) { throw new IllegalStateException("Password are not the same"); @@ -29,6 +31,7 @@ public void changePassword(ChangePasswordRequest request, Principal connectedUse // update the password user.setPassword(passwordEncoder.encode(request.getNewPassword())); + // save the new password repository.save(user); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 71b71d15..cef44a3a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,8 +1,8 @@ spring: datasource: url: jdbc:postgresql://localhost:5432/jwt_security - username: username - password: password + username: postgres + password: postgres driver-class-name: org.postgresql.Driver jpa: hibernate: @@ -14,6 +14,13 @@ spring: database: postgresql database-platform: org.hibernate.dialect.PostgreSQLDialect +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + enabled: true + application: security: jwt: diff --git a/src/test/java/com/alibou/security/SecurityApplicationTests.java b/src/test/java/com/omore/security/SecurityApplicationTests.java similarity index 68% rename from src/test/java/com/alibou/security/SecurityApplicationTests.java rename to src/test/java/com/omore/security/SecurityApplicationTests.java index 6e2729f7..575baa89 100644 --- a/src/test/java/com/alibou/security/SecurityApplicationTests.java +++ b/src/test/java/com/omore/security/SecurityApplicationTests.java @@ -1,4 +1,4 @@ -package com.alibou.security; +package com.omore.security; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @@ -6,8 +6,8 @@ @SpringBootTest class SecurityApplicationTests { - @Test - void contextLoads() { - } + @Test + void contextLoads() { + } }