diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1c33407 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,108 @@ +# ๐Ÿค Contributing to Spring E-Learning & CBT Platform + +Thank you for your interest in contributing to this project! We welcome contributions from the community to help improve this open-source platform. + +--- + +## ๐Ÿ› ๏ธ Development Setup + +### Prerequisites +- **Java 21 LTS** (Eclipse Temurin recommended) +- **Node.js 20+** and **npm** +- **Docker & Docker Compose** +- **PostgreSQL 16** (or run via Docker) +- **Redis 7** (or run via Docker) + +### Quick Start with Docker +The easiest way to spin up the local development environment is with Docker Compose: + +```bash +# 1. Clone the repository +git clone https://github.com/mfarim/spring-elearning-react.git +cd spring-elearning-react + +# 2. Start PostgreSQL, Redis, MinIO, Backend, and Frontend +docker compose up -d + +# 3. Check health status +docker compose ps +curl http://localhost:8080/actuator/health +``` + +### Running Backend Locally +```bash +cd backend + +# Ensure PostgreSQL and Redis are running +./mvnw clean spring-boot:run +``` + +API documentation will be accessible at: +- **Swagger UI**: [http://localhost:8080/swagger-ui.html](http://localhost:8080/swagger-ui.html) +- **Health Check**: [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health) + +### Running Frontend Locally +```bash +cd frontend + +npm install +npm run dev +``` +Frontend will be accessible at [http://localhost:3000](http://localhost:3000). + +--- + +## ๐Ÿ“‚ Seed Demo Data +To populate the database with realistic sample users, classrooms, subjects, learning materials, and CBT examinations: + +```bash +# Connect to PostgreSQL container and load the seed script: +docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql +``` + +Default credentials: +- **Admin**: `admin@elearning.com` / `password` +- **Teacher**: `teacher@elearning.com` / `password` +- **Student**: `student@elearning.com` / `password` + +--- + +## ๐Ÿงช Testing & Code Quality + +Before opening a pull request, ensure that your code complies with the project's formatting and testing checks: + +### Backend Tests +```bash +cd backend +./mvnw clean test +``` + +### Frontend Lint & Build +```bash +cd frontend +npm run lint +npm run build +``` + +--- + +## ๐Ÿ”€ Pull Request Guidelines + +1. **Fork** the repository and create your branch from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` +2. **Commit** your changes with clear, descriptive commit messages: + ```bash + git commit -m "feat(auth): implement refresh token rotation" + ``` +3. **Push** to your fork: + ```bash + git push origin feature/your-feature-name + ``` +4. **Open a Pull Request** describing the problem, solution, and screenshots if UI changes are involved. + +--- + +## ๐Ÿ›ก๏ธ Security Vulnerabilities +If you discover a potential security vulnerability, please report it privately to the maintainers rather than opening a public issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0a20c7e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 mfarim + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.id.md b/README.id.md index 601e240..63df701 100644 --- a/README.id.md +++ b/README.id.md @@ -178,13 +178,19 @@ cd spring-elearning-react # Jalankan seluruh stack (PostgreSQL 16, Redis 7, Spring Boot backend, dan React frontend) docker compose up -d + +# (Opsional) Muat data demo siap pakai (guru, kelas, mata pelajaran, ujian CBT) +docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql ``` Akses layanan: - **Portal Web Frontend**: [http://localhost:3000](http://localhost:3000) - **Backend API Spring Boot**: [http://localhost:8080](http://localhost:8080) +- **Actuator Health Probe**: [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health) - **Dokumentasi Swagger UI Interaktif**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html) +Panduan kontribusi dan standar kode open source dapat dilihat di [CONTRIBUTING.md](CONTRIBUTING.md). + --- ### Setup Manual (Pengembangan Lokal) diff --git a/README.md b/README.md index cc37d5d..0c2db5a 100644 --- a/README.md +++ b/README.md @@ -179,13 +179,19 @@ cd spring-elearning-react # Launch full stack (PostgreSQL 16, Redis 7, Spring Boot backend, and React frontend) docker compose up -d + +# (Optional) Seed realistic demo data (teachers, classrooms, subjects, exams) +docker exec -i elearning-postgres psql -U postgres -d elearning < backend/src/main/resources/db/migration/seed_demo_data.sql ``` Access the services: - **Frontend Web Portal**: [http://localhost:3000](http://localhost:3000) - **Spring Boot Backend API**: [http://localhost:8080](http://localhost:8080) +- **Actuator Health Probe**: [http://localhost:8080/actuator/health](http://localhost:8080/actuator/health) - **Interactive Swagger UI Documentation**: [http://localhost:8080/swagger-ui/index.html](http://localhost:8080/swagger-ui/index.html) +For contribution guidelines and coding standards, see [CONTRIBUTING.md](CONTRIBUTING.md). + --- ### Manual Setup (Local Development) diff --git a/backend/pom.xml b/backend/pom.xml index a375341..156f152 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -40,6 +40,10 @@ org.springframework.boot spring-boot-starter-websocket + + org.springframework.boot + spring-boot-starter-actuator + diff --git a/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java index fbda12e..207b564 100644 --- a/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/elearning/common/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package com.elearning.common.exception; import com.elearning.common.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.AccessDeniedException; @@ -14,6 +15,7 @@ import java.util.Map; @RestControllerAdvice +@Slf4j public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) @@ -46,6 +48,12 @@ public ResponseEntity> handleAccessDenied(AccessDeniedExceptio .body(ApiResponse.error(ex.getMessage() != null ? ex.getMessage() : "Access denied")); } + @ExceptionHandler(SecurityException.class) + public ResponseEntity> handleSecurityException(SecurityException ex) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.error(ex.getMessage())); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -54,7 +62,8 @@ public ResponseEntity> handleIllegalArgument(IllegalArgumentEx @ExceptionHandler(Exception.class) public ResponseEntity> handleGenericException(Exception ex) { + log.error("Unhandled server exception: ", ex); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error("An unexpected error occurred: " + ex.getMessage())); + .body(ApiResponse.error("An unexpected error occurred. Please contact the administrator.")); } } diff --git a/backend/src/main/java/com/elearning/common/service/FileStorageService.java b/backend/src/main/java/com/elearning/common/service/FileStorageService.java index 0859931..17ca9a3 100644 --- a/backend/src/main/java/com/elearning/common/service/FileStorageService.java +++ b/backend/src/main/java/com/elearning/common/service/FileStorageService.java @@ -29,6 +29,10 @@ public FileStorageService(@Value("${app.storage.upload-dir:./storage/uploads}") public String storeFile(MultipartFile file, String subDirectory) { String originalFileName = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename())); + if (originalFileName.contains("..")) { + throw new IllegalArgumentException("Filename contains invalid path sequence " + originalFileName); + } + String extension = ""; int i = originalFileName.lastIndexOf('.'); if (i >= 0) { @@ -38,17 +42,19 @@ public String storeFile(MultipartFile file, String subDirectory) { String fileName = UUID.randomUUID() + extension; try { - if (originalFileName.contains("..")) { - throw new IllegalArgumentException("Filename contains invalid path sequence " + originalFileName); - } - Path targetDir = this.fileStorageLocation; if (subDirectory != null && !subDirectory.trim().isEmpty()) { targetDir = this.fileStorageLocation.resolve(subDirectory).normalize(); + if (!targetDir.startsWith(this.fileStorageLocation)) { + throw new SecurityException("Target sub-directory is outside base upload directory: " + subDirectory); + } Files.createDirectories(targetDir); } - Path targetLocation = targetDir.resolve(fileName); + Path targetLocation = targetDir.resolve(fileName).normalize(); + if (!targetLocation.startsWith(this.fileStorageLocation)) { + throw new SecurityException("Target file location is outside base upload directory"); + } Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING); return (subDirectory != null && !subDirectory.trim().isEmpty() ? subDirectory + "/" : "") + fileName; @@ -60,6 +66,9 @@ public String storeFile(MultipartFile file, String subDirectory) { public Resource loadFileAsResource(String filePath) { try { Path file = this.fileStorageLocation.resolve(filePath).normalize(); + if (!file.startsWith(this.fileStorageLocation)) { + throw new SecurityException("Access to path outside upload directory is forbidden: " + filePath); + } Resource resource = new UrlResource(file.toUri()); if (resource.exists() && resource.isReadable()) { return resource; @@ -75,6 +84,9 @@ public void deleteFile(String filePath) { if (filePath == null || filePath.trim().isEmpty()) return; try { Path file = this.fileStorageLocation.resolve(filePath).normalize(); + if (!file.startsWith(this.fileStorageLocation)) { + throw new SecurityException("Cannot delete file outside storage directory"); + } Files.deleteIfExists(file); } catch (IOException ignored) { } diff --git a/backend/src/main/java/com/elearning/config/SecurityConfig.java b/backend/src/main/java/com/elearning/config/SecurityConfig.java index 1f88629..3276741 100644 --- a/backend/src/main/java/com/elearning/config/SecurityConfig.java +++ b/backend/src/main/java/com/elearning/config/SecurityConfig.java @@ -48,9 +48,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/auth/**", "/api/v1/files/**", "/swagger-ui/**", - "/swagger-ui.html", "/api-docs/**", "/v3/api-docs/**", + "/actuator/health/**", + "/actuator/info", "/ws/**" ).permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") diff --git a/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java b/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java index 2e5ce36..71bef3a 100644 --- a/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java +++ b/backend/src/main/java/com/elearning/modules/auth/controller/AuthController.java @@ -29,6 +29,13 @@ public ResponseEntity> login(@Valid @RequestBody Login return ResponseEntity.ok(ApiResponse.ok(response, "Login successful")); } + @PostMapping("/auth/refresh") + @Operation(summary = "Refresh JWT Token", description = "Obtain a fresh access token using a valid refresh token") + public ResponseEntity> refreshToken(@Valid @RequestBody RefreshTokenRequest request) { + AuthResponse response = authService.refreshToken(request); + return ResponseEntity.ok(ApiResponse.ok(response, "Token refreshed successfully")); + } + @PostMapping("/auth/register") @Operation(summary = "Student Registration", description = "Register a new student account") public ResponseEntity> register(@Valid @RequestBody RegisterRequest request) { diff --git a/backend/src/main/java/com/elearning/modules/auth/dto/RefreshTokenRequest.java b/backend/src/main/java/com/elearning/modules/auth/dto/RefreshTokenRequest.java new file mode 100644 index 0000000..5516c11 --- /dev/null +++ b/backend/src/main/java/com/elearning/modules/auth/dto/RefreshTokenRequest.java @@ -0,0 +1,20 @@ +package com.elearning.modules.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Refresh token request payload") +public class RefreshTokenRequest { + + @NotBlank(message = "Refresh token is required") + @Schema(description = "Valid JWT refresh token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; +} diff --git a/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java b/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java index 8726e90..506b0cd 100644 --- a/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java +++ b/backend/src/main/java/com/elearning/modules/auth/service/AuthService.java @@ -15,6 +15,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; @@ -62,6 +63,42 @@ public AuthResponse login(LoginRequest request) { .build(); } + @Transactional(readOnly = true) + public AuthResponse refreshToken(RefreshTokenRequest request) { + String token = request.getRefreshToken(); + if (token == null || !jwtProvider.validateToken(token)) { + throw new BadCredentialsException("Invalid or expired refresh token"); + } + + Long userId = jwtProvider.getUserIdFromToken(token); + User user = userRepository.findById(userId) + .orElseThrow(() -> new BadCredentialsException("User associated with refresh token not found")); + + if (!user.isActive()) { + throw new AccessDeniedException("User account is inactive"); + } + + Long impersonatorAdminId = jwtProvider.getImpersonatorAdminIdFromToken(token); + UserPrincipal principal = UserPrincipal.create(user, impersonatorAdminId); + + String newAccessToken = jwtProvider.generateAccessToken(principal); + String newRefreshToken = jwtProvider.generateRefreshToken(principal); + + List roles = principal.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + + return AuthResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .userId(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .roles(roles) + .impersonatorAdminId(impersonatorAdminId) + .build(); + } + @Transactional public AuthResponse register(RegisterRequest request) { if (userRepository.existsByEmail(request.getEmail())) { diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 2eee5be..9ec254b 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -64,6 +64,17 @@ springdoc: path: /swagger-ui.html operationsSorter: method +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: when-authorized + probes: + enabled: true + logging: level: com.elearning: DEBUG diff --git a/backend/src/test/java/com/elearning/common/service/FileStorageServiceTest.java b/backend/src/test/java/com/elearning/common/service/FileStorageServiceTest.java new file mode 100644 index 0000000..e6f7817 --- /dev/null +++ b/backend/src/test/java/com/elearning/common/service/FileStorageServiceTest.java @@ -0,0 +1,85 @@ +package com.elearning.common.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.core.io.Resource; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.IOException; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class FileStorageServiceTest { + + @TempDir + Path tempUploadDir; + + private FileStorageService fileStorageService; + + @BeforeEach + void setUp() { + fileStorageService = new FileStorageService(tempUploadDir.toString()); + } + + @Test + void testStoreAndLoadFile_Success() throws IOException { + MockMultipartFile file = new MockMultipartFile( + "file", + "sample-material.pdf", + "application/pdf", + "dummy content for testing".getBytes() + ); + + String storedPath = fileStorageService.storeFile(file, "materials"); + assertNotNull(storedPath); + assertTrue(storedPath.startsWith("materials/")); + + Resource resource = fileStorageService.loadFileAsResource(storedPath); + assertTrue(resource.exists()); + assertTrue(resource.isReadable()); + } + + @Test + void testStoreFile_PathTraversalInFilename_ThrowsException() { + MockMultipartFile file = new MockMultipartFile( + "file", + "../../etc/passwd.txt", + "text/plain", + "malicious content".getBytes() + ); + + assertThrows(IllegalArgumentException.class, () -> + fileStorageService.storeFile(file, "materials") + ); + } + + @Test + void testStoreFile_PathTraversalInSubDirectory_ThrowsSecurityException() { + MockMultipartFile file = new MockMultipartFile( + "file", + "normal.pdf", + "application/pdf", + "hello".getBytes() + ); + + assertThrows(SecurityException.class, () -> + fileStorageService.storeFile(file, "../../outside") + ); + } + + @Test + void testLoadFile_PathTraversal_ThrowsSecurityException() { + assertThrows(SecurityException.class, () -> + fileStorageService.loadFileAsResource("../../application.yml") + ); + } + + @Test + void testDeleteFile_PathTraversal_ThrowsSecurityException() { + assertThrows(SecurityException.class, () -> + fileStorageService.deleteFile("../../secret.txt") + ); + } +} diff --git a/backend/src/test/java/com/elearning/security/JwtProviderTest.java b/backend/src/test/java/com/elearning/security/JwtProviderTest.java new file mode 100644 index 0000000..59e3184 --- /dev/null +++ b/backend/src/test/java/com/elearning/security/JwtProviderTest.java @@ -0,0 +1,63 @@ +package com.elearning.security; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class JwtProviderTest { + + private static final String BASE64_SECRET = "dGhpcy1pcy1hLXZlcnktc2VjdXJlLTI1Ni1iaXQtbG9uZy1zZWNyZXQta2V5LWZvci1qd3Qtc2lnbmluZw=="; + private JwtProvider jwtProvider; + + @BeforeEach + void setUp() { + jwtProvider = new JwtProvider(BASE64_SECRET, 900000, 604800000); + } + + @Test + void testGenerateAndValidateAccessToken() { + UserPrincipal principal = new UserPrincipal( + 1L, + "Admin User", + "admin@elearning.com", + "password", + true, + List.of(new SimpleGrantedAuthority("ROLE_ADMIN")), + null + ); + + String accessToken = jwtProvider.generateAccessToken(principal); + assertNotNull(accessToken); + assertTrue(jwtProvider.validateToken(accessToken)); + assertEquals(1L, jwtProvider.getUserIdFromToken(accessToken)); + assertNull(jwtProvider.getImpersonatorAdminIdFromToken(accessToken)); + } + + @Test + void testGenerateAndValidateRefreshToken() { + UserPrincipal principal = new UserPrincipal( + 2L, + "Student User", + "student@elearning.com", + "password", + true, + List.of(new SimpleGrantedAuthority("ROLE_STUDENT")), + 1L // impersonated by admin 1 + ); + + String refreshToken = jwtProvider.generateRefreshToken(principal); + assertNotNull(refreshToken); + assertTrue(jwtProvider.validateToken(refreshToken)); + assertEquals(2L, jwtProvider.getUserIdFromToken(refreshToken)); + } + + @Test + void testValidateToken_InvalidSignature_ReturnsFalse() { + String invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.invalidSignature12345"; + assertFalse(jwtProvider.validateToken(invalidToken)); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ee03b40..bcd9e88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,9 +57,19 @@ services: SPRING_DATASOURCE_PASSWORD: postgres SPRING_DATA_REDIS_HOST: redis SPRING_DATA_REDIS_PORT: 6379 - APP_JWT_SECRET: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970 + JWT_SECRET: dGhpcy1pcy1hLXZlcnktc2VjdXJlLTI1Ni1iaXQtbG9uZy1zZWNyZXQta2V5LWZvci1qd3Qtc2lnbmluZw== + APP_JWT_SECRET: dGhpcy1pcy1hLXZlcnktc2VjdXJlLTI1Ni1iaXQtbG9uZy1zZWNyZXQta2V5LWZvci1qd3Qtc2lnbmluZw== + STORAGE_UPLOAD_DIR: /app/storage/uploads ports: - "8080:8080" + volumes: + - backend_storage:/app/storage/uploads + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s depends_on: postgres: condition: service_healthy @@ -75,9 +85,11 @@ services: ports: - "80:80" depends_on: - - backend + backend: + condition: service_healthy volumes: postgres_data: redis_data: minio_data: + backend_storage: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 26aca86..1722238 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -16,6 +16,14 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location /actuator/ { + proxy_pass http://backend:8080/actuator/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location /ws/ { proxy_pass http://backend:8080/ws/; proxy_http_version 1.1; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 670991e..bdd0255 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -18,16 +18,100 @@ api.interceptors.request.use( (error) => Promise.reject(error) ); +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (err: any) => void; +}> = []; + +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token!); + } + }); + failedQueue = []; +}; + api.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('token'); - localStorage.removeItem('user'); - if (window.location.pathname !== '/login') { - window.location.href = '/login'; + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + // Do not try to refresh if the 401 was from login, register, or refresh itself + if ( + originalRequest.url?.includes('/auth/login') || + originalRequest.url?.includes('/auth/register') || + originalRequest.url?.includes('/auth/refresh') + ) { + return Promise.reject(error); + } + + const refreshToken = localStorage.getItem('refreshToken'); + if (!refreshToken) { + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + return Promise.reject(error); + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((newToken) => { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return api(originalRequest); + }) + .catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + try { + const response = await axios.post('/api/v1/auth/refresh', { + refreshToken, + }); + + const data = response.data?.data; + const newAccessToken = data?.accessToken; + const newRefreshToken = data?.refreshToken; + + if (newAccessToken) { + localStorage.setItem('token', newAccessToken); + if (newRefreshToken) { + localStorage.setItem('refreshToken', newRefreshToken); + } + + api.defaults.headers.common.Authorization = `Bearer ${newAccessToken}`; + processQueue(null, newAccessToken); + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return api(originalRequest); + } else { + throw new Error('Refresh token response missing access token'); + } + } catch (refreshErr) { + processQueue(refreshErr, null); + localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + if (window.location.pathname !== '/login') { + window.location.href = '/login'; + } + return Promise.reject(refreshErr); + } finally { + isRefreshing = false; } } + return Promise.reject(error); } ); diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index b63559e..32b89ae 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -30,6 +30,7 @@ export const useAuthStore = create((set) => ({ set({ user, token, isAuthenticated: true, isLoading: false }); } catch { localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); localStorage.removeItem('user'); set({ user: null, token: null, isAuthenticated: false, isLoading: false }); } @@ -54,6 +55,9 @@ export const useAuthStore = create((set) => ({ }; localStorage.setItem('token', data.accessToken); + if (data.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } localStorage.setItem('user', JSON.stringify(user)); set({ user, token: data.accessToken, isAuthenticated: true, isLoading: false }); @@ -65,6 +69,7 @@ export const useAuthStore = create((set) => ({ logout: () => { localStorage.removeItem('token'); + localStorage.removeItem('refreshToken'); localStorage.removeItem('user'); set({ user: null, token: null, isAuthenticated: false }); window.location.href = '/login'; @@ -84,6 +89,9 @@ export const useAuthStore = create((set) => ({ }; localStorage.setItem('token', data.accessToken); + if (data.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } localStorage.setItem('user', JSON.stringify(user)); set({ user, token: data.accessToken }); @@ -104,6 +112,9 @@ export const useAuthStore = create((set) => ({ }; localStorage.setItem('token', data.accessToken); + if (data.refreshToken) { + localStorage.setItem('refreshToken', data.refreshToken); + } localStorage.setItem('user', JSON.stringify(user)); set({ user, token: data.accessToken });