From 4e43df80758c47e039e8617525f46ad2a5cf3577 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 1 Apr 2024 18:58:20 +0200 Subject: [PATCH 01/35] Added sample security. Though this breaks the app. --- build.gradle | 5 ++++ .../config/CustomAuthenticationFilter.java | 17 +++++++++++ .../sharepulse/config/SecurityConfig.java | 29 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 src/main/java/de/w665/sharepulse/config/CustomAuthenticationFilter.java create mode 100644 src/main/java/de/w665/sharepulse/config/SecurityConfig.java diff --git a/build.gradle b/build.gradle index 45eb111..5784301 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,11 @@ dependencies { implementation 'com.rethinkdb:rethinkdb-driver:2.4.4' // https://mvnrepository.com/artifact/com.google.code.gson/gson implementation group: 'com.google.code.gson', name: 'gson', version: '2.10.1' + // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security + implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '3.2.4' + // https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.5' + } bootJar { diff --git a/src/main/java/de/w665/sharepulse/config/CustomAuthenticationFilter.java b/src/main/java/de/w665/sharepulse/config/CustomAuthenticationFilter.java new file mode 100644 index 0000000..3defc5a --- /dev/null +++ b/src/main/java/de/w665/sharepulse/config/CustomAuthenticationFilter.java @@ -0,0 +1,17 @@ +package de.w665.sharepulse.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import org.springframework.web.filter.GenericFilterBean; + +import java.io.IOException; + +public class CustomAuthenticationFilter extends GenericFilterBean { + @Override + public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException { + // Custom logic here + filterChain.doFilter(servletRequest, servletResponse); + } +} diff --git a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java new file mode 100644 index 0000000..bd07caf --- /dev/null +++ b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java @@ -0,0 +1,29 @@ +package de.w665.sharepulse.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +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.LogoutConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/admin/**").authenticated() + .anyRequest().permitAll() + ) + .formLogin(formLogin -> formLogin + .loginPage("/login") + .permitAll() + ) + .logout(LogoutConfigurer::permitAll) + .rememberMe(Customizer.withDefaults()); + return http.build(); + } +} From 80610b774acc2209bc13c68e006946fef98d5380 Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 1 Apr 2024 21:27:30 +0200 Subject: [PATCH 02/35] Updated packages and added CSRF ignore --- frontend/angular.json | 3 + frontend/package-lock.json | 73 +++++-------------- .../sharepulse/config/SecurityConfig.java | 3 + 3 files changed, 23 insertions(+), 56 deletions(-) diff --git a/frontend/angular.json b/frontend/angular.json index 8fbc1d7..3c4ab0f 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -97,5 +97,8 @@ } } } + }, + "cli": { + "analytics": false } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4a078b5..27cc285 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5945,17 +5945,17 @@ "dev": true }, "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", + "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", "dev": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.1", + "body-parser": "1.20.2", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.5.0", + "cookie": "0.6.0", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", @@ -5986,34 +5986,10 @@ "node": ">= 0.10.0" } }, - "node_modules/express/node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/express/node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "dev": true, "engines": { "node": ">= 0.6" @@ -6052,21 +6028,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/express/node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "dev": true, - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/express/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -6278,9 +6239,9 @@ "dev": true }, "node_modules/follow-redirects": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", - "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", "funding": [ { "type": "individual", @@ -7076,9 +7037,9 @@ } }, "node_modules/ip": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", - "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", + "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==", "dev": true }, "node_modules/ipaddr.js": { @@ -12007,9 +11968,9 @@ } }, "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dev": true, "dependencies": { "colorette": "^2.0.10", diff --git a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java index bd07caf..610f085 100644 --- a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java +++ b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java @@ -23,6 +23,9 @@ public class SecurityConfig { .permitAll() ) .logout(LogoutConfigurer::permitAll) + .csrf(csrf -> csrf + .ignoringRequestMatchers("/api/v1/**") // Disable CSRF for /api/** + ) .rememberMe(Customizer.withDefaults()); return http.build(); } From da5abba5b9758fab18d1c1f33a2af090852a24ac Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 1 Apr 2024 22:32:41 +0200 Subject: [PATCH 03/35] Added /public to public endpoints. Updated version --- build.gradle | 2 +- frontend/src/app/credits/credits.component.ts | 2 +- frontend/src/app/download/download.component.ts | 6 +++--- frontend/src/app/upload/upload.component.ts | 6 +++--- .../java/de/w665/sharepulse/rest/ApiRestController.java | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 5784301..e75d748 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'de.w665' -version = '1.1.1' +version = '1.1.2' java { sourceCompatibility = '21' diff --git a/frontend/src/app/credits/credits.component.ts b/frontend/src/app/credits/credits.component.ts index 2aacf5b..35df6f2 100644 --- a/frontend/src/app/credits/credits.component.ts +++ b/frontend/src/app/credits/credits.component.ts @@ -32,7 +32,7 @@ export class CreditsComponent { } getVersion() { - axios.get(this.developmentStore.getBaseUrl() + 'api/v1/version') + axios.get(this.developmentStore.getBaseUrl() + 'api/v1/public/version') .then((response) => { this.version = response.data; }) diff --git a/frontend/src/app/download/download.component.ts b/frontend/src/app/download/download.component.ts index 8e23240..9e7d243 100644 --- a/frontend/src/app/download/download.component.ts +++ b/frontend/src/app/download/download.component.ts @@ -99,7 +99,7 @@ export class DownloadComponent { axios({ method: 'get', - url: this.developmentStore.getBaseUrl() + 'api/v1/speed-test', + url: this.developmentStore.getBaseUrl() + 'api/v1/public/speed-test', responseType: 'arraybuffer', }) .then(response => { @@ -119,7 +119,7 @@ export class DownloadComponent { private getDownloadInfo() { axios({ method: 'get', - url: this.developmentStore.getBaseUrl() + 'api/v1/download-info?fileId=' + this.fileId, + url: this.developmentStore.getBaseUrl() + 'api/v1/public/download-info?fileId=' + this.fileId, responseType: 'json', }) .then(response => { @@ -138,7 +138,7 @@ export class DownloadComponent { this.fileDownloadStarted = true; axios({ method: 'get', - url: this.developmentStore.getBaseUrl() + 'api/v1/download?fileId=' + this.fileId + '&password=' + this.filePassword, + url: this.developmentStore.getBaseUrl() + 'api/v1/public/download?fileId=' + this.fileId + '&password=' + this.filePassword, responseType: 'arraybuffer', onDownloadProgress: (progressEvent) => { const endTime = new Date().getTime(); diff --git a/frontend/src/app/upload/upload.component.ts b/frontend/src/app/upload/upload.component.ts index df1cce8..a62099a 100644 --- a/frontend/src/app/upload/upload.component.ts +++ b/frontend/src/app/upload/upload.component.ts @@ -97,7 +97,7 @@ export class UploadComponent { } }; - axios.post(this.developmentStore.getBaseUrl() + 'api/v1/upload', formData, config) + axios.post(this.developmentStore.getBaseUrl() + 'api/v1/public/upload', formData, config) .then(response => { console.log('Upload completed successfully!'); console.log(response.data); @@ -119,7 +119,7 @@ export class UploadComponent { passwordUrlPart = `&password=${fileDetails.password}`; } const downloadUrl = `${baseUrl}download?fileId=${fileId}${passwordUrlPart}`; - const deleteUrl = `${baseUrl}api/v1/deletefile?fileId=${fileId}${passwordUrlPart}`; + const deleteUrl = `${baseUrl}api/v1/public/deletefile?fileId=${fileId}${passwordUrlPart}`; const statusUrl = `${baseUrl}status?fileId=${fileId}${passwordUrlPart}`; return { downloadUrl, @@ -151,7 +151,7 @@ export class UploadComponent { axios({ method: 'post', - url: this.developmentStore.getBaseUrl() + 'api/v1/upload-speed-test', + url: this.developmentStore.getBaseUrl() + 'api/v1/public/upload-speed-test', data: uint8View, headers: { 'Content-Type': 'application/octet-stream', diff --git a/src/main/java/de/w665/sharepulse/rest/ApiRestController.java b/src/main/java/de/w665/sharepulse/rest/ApiRestController.java index 828a67e..508b4ba 100644 --- a/src/main/java/de/w665/sharepulse/rest/ApiRestController.java +++ b/src/main/java/de/w665/sharepulse/rest/ApiRestController.java @@ -4,6 +4,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/public") public abstract class ApiRestController { } From a93288e31c180d9ad25e6875c715e7cf9c2a2336 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 Apr 2024 18:25:39 +0200 Subject: [PATCH 04/35] - Added sample JWT authentication service - Added jwt secret and duration to properties - Added jwt dependencies - Added SecureApiRestController - Added UserEntity - Added UserRepository.java --- build.gradle | 4 ++ .../sharepulse/config/SecurityConfig.java | 4 +- .../sharepulse/db/repo/UserRepository.java | 4 ++ .../java/de/w665/sharepulse/model/User.java | 17 +++++++ .../rest/SecureApiRestController.java | 9 ++++ .../mappings/AuthenticationController.java | 33 ++++++++++++ .../service/AuthenticationService.java | 51 +++++++++++++++++++ src/main/resources/application.properties | 4 +- 8 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/main/java/de/w665/sharepulse/db/repo/UserRepository.java create mode 100644 src/main/java/de/w665/sharepulse/model/User.java create mode 100644 src/main/java/de/w665/sharepulse/rest/SecureApiRestController.java create mode 100644 src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java create mode 100644 src/main/java/de/w665/sharepulse/service/AuthenticationService.java diff --git a/build.gradle b/build.gradle index e75d748..a8d6cb7 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,10 @@ dependencies { implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '3.2.4' // https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.5' + // https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.5' + // https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-orgjson + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-orgjson', version: '0.12.5' } diff --git a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java index 610f085..75999f4 100644 --- a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java +++ b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java @@ -15,11 +15,11 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/admin/**").authenticated() + .requestMatchers("/api/v1/secure/**").authenticated() .anyRequest().permitAll() ) .formLogin(formLogin -> formLogin - .loginPage("/login") + .loginPage("/management/login") .permitAll() ) .logout(LogoutConfigurer::permitAll) diff --git a/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java b/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java new file mode 100644 index 0000000..d7ec26b --- /dev/null +++ b/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java @@ -0,0 +1,4 @@ +package de.w665.sharepulse.db.repo; + +public class UserRepository { +} diff --git a/src/main/java/de/w665/sharepulse/model/User.java b/src/main/java/de/w665/sharepulse/model/User.java new file mode 100644 index 0000000..e0d821d --- /dev/null +++ b/src/main/java/de/w665/sharepulse/model/User.java @@ -0,0 +1,17 @@ +package de.w665.sharepulse.model; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class User { + private String username; + private String password; + private String email; + private String role; +} diff --git a/src/main/java/de/w665/sharepulse/rest/SecureApiRestController.java b/src/main/java/de/w665/sharepulse/rest/SecureApiRestController.java new file mode 100644 index 0000000..3b798e3 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/SecureApiRestController.java @@ -0,0 +1,9 @@ +package de.w665.sharepulse.rest; + +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/secure") +public abstract class SecureApiRestController { +} diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java b/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java new file mode 100644 index 0000000..9e5d1c4 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java @@ -0,0 +1,33 @@ +package de.w665.sharepulse.rest.mappings; + +import de.w665.sharepulse.service.AuthenticationService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/v1/auth") +public class AuthenticationController { + + private final AuthenticationService authenticationService; + + public AuthenticationController(AuthenticationService authenticationService) { + this.authenticationService = authenticationService; + } + + @PostMapping("/login") + public ResponseEntity createAuthenticationToken() { + String token = authenticationService.authenticate("test", "test"); + + Map response = new HashMap<>(); + response.put("token", token); + + return new ResponseEntity<>(response, HttpStatus.OK); + } +} diff --git a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java new file mode 100644 index 0000000..2107c10 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java @@ -0,0 +1,51 @@ +package de.w665.sharepulse.service; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Base64; +import java.util.Date; + +@Slf4j +@Service +public class AuthenticationService { + @Value("${secureapi.jwt.secret}") + private String secretString; + + @Value("${secureapi.jwt.expiration}") + private long expirationTime; // in milliseconds + + private SecretKey secretKey; + + @PostConstruct + public void init() { + System.out.println("Initializing secret key"); + byte[] encodedKey = Base64.getEncoder().encode(secretString.getBytes()); // encode the secret key + this.secretKey = Keys.hmacShaKeyFor(encodedKey); + } + + public String authenticate(String username, String password) { + // validate user credentials with repository and password hash algorithm + + return generateToken(username); + // throw new RuntimeException("User authentication failed"); + } + + private String generateToken(String username) { + long nowMillis = System.currentTimeMillis(); + Date now = new Date(nowMillis); + Date expiryDate = new Date(nowMillis + expirationTime); + + return Jwts.builder() + .subject(username) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3e5ad1a..08636ac 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,4 +25,6 @@ spring.web.resources.static-locations=classpath:/static/browser/ spring.data.rest.base-path=/api/v1 # Miscellaneous server.port=80 -spring.application.name=sharepulse \ No newline at end of file +spring.application.name=sharepulse +secureapi.jwt.secret=sampleKeyToChangeInProduction +secureapi.jwt.expiration=3600000 \ No newline at end of file From 4dd5093abc9dbb5f87f972f5fcc3ee42dda595c0 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 Apr 2024 22:42:53 +0200 Subject: [PATCH 05/35] - Added basic JWT auth logic - Added user database management - Added password hashing - Added user config --- .../w665/sharepulse/db/RethinkDBService.java | 44 +++++++++++++++++-- .../sharepulse/db/repo/UserRepository.java | 37 ++++++++++++++++ .../mappings/AuthenticationController.java | 8 +++- .../w665/sharepulse/rest/mappings/Upload.java | 4 +- .../rest/ro/AuthenticationRequest.java | 15 +++++++ .../service/AuthenticationService.java | 26 ++++++++--- src/main/resources/application.properties | 3 ++ 7 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 src/main/java/de/w665/sharepulse/rest/ro/AuthenticationRequest.java diff --git a/src/main/java/de/w665/sharepulse/db/RethinkDBService.java b/src/main/java/de/w665/sharepulse/db/RethinkDBService.java index da1a8d0..321af66 100644 --- a/src/main/java/de/w665/sharepulse/db/RethinkDBService.java +++ b/src/main/java/de/w665/sharepulse/db/RethinkDBService.java @@ -3,33 +3,43 @@ package de.w665.sharepulse.db; import com.rethinkdb.RethinkDB; import com.rethinkdb.gen.exc.ReqlOpFailedError; import com.rethinkdb.net.Connection; +import de.w665.sharepulse.db.repo.UserRepository; +import de.w665.sharepulse.model.User; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; +import java.util.Optional; @Slf4j @Service public class RethinkDBService { private final RethinkDBConfig config; - private final RethinkDB r; private final Connection connection; + private final UserRepository userRepository; @Value("${sharepulse.auto-reset-on-startup}") private boolean autoResetOnStartup; + @Value("${sharepulse.management.user.username}") + private String defaultUsername; + @Value("${sharepulse.management.user.password}") + private String defaultPassword; + + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @Autowired - public RethinkDBService(RethinkDBConfig config, RethinkDBConnector connector) { + public RethinkDBService(RethinkDBConfig config, RethinkDBConnector connector, UserRepository userRepository) { this.config = config; - // mapping to private vars for easier access this.r = connector.getR(); this.connection = connector.getConnection(); + this.userRepository = userRepository; } @PostConstruct @@ -81,9 +91,37 @@ public class RethinkDBService { log.debug("Table 'expired_file_uploads' cleared successfully."); } } + + // rethinkdb check if table users exists + try { + r.db(config.getDatabase()).tableCreate("users").run(connection).stream(); + log.debug("Table 'users' created successfully."); + } catch (ReqlOpFailedError e) { + log.debug("Table 'users' already exists."); + if(autoResetOnStartup) { + log.debug("Clearing content..."); + r.db(config.getDatabase()).table("users").delete().run(connection); + log.debug("Table 'users' cleared successfully."); + } + } + + initializeAdminUser(); + log.info("Database ready for operation!"); } + private void initializeAdminUser() { + Optional adminUser = userRepository.retrieveUserByUsername("admin"); + if(adminUser.isEmpty()) { + User user = new User(); + user.setUsername(defaultUsername); + user.setPassword(passwordEncoder.encode(defaultPassword)); + user.setRole("admin"); + userRepository.insertUser(user); + log.debug("Admin user created with default credentials. Username: admin, Password: admin"); + } + } + @PreDestroy public void close() { if (connection != null) { diff --git a/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java b/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java index d7ec26b..9113567 100644 --- a/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java +++ b/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java @@ -1,4 +1,41 @@ package de.w665.sharepulse.db.repo; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.rethinkdb.RethinkDB; +import com.rethinkdb.net.Connection; +import de.w665.sharepulse.db.RethinkDBConfig; +import de.w665.sharepulse.db.RethinkDBConnector; +import de.w665.sharepulse.model.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; +import java.util.*; + +@Repository public class UserRepository { + private final RethinkDB r; + private final Connection connection; + private final RethinkDBConfig config; + @Autowired + public UserRepository(RethinkDBConnector connector, RethinkDBConfig config) { + this.r = connector.getR(); + this.connection = connector.getConnection(); + this.config = config; + } + + public Optional retrieveUserByUsername(String username) { + try { + User user = r.db(config.getDatabase()).table("users") + .filter(r.hashMap("username", username)) + .run(connection, User.class) + .next(); + return Optional.ofNullable(user); + } catch (NoSuchElementException e) { + return Optional.empty(); + } + } + + public void insertUser(User user) { + r.db(config.getDatabase()).table("users").insert(user).run(connection); + } } diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java b/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java index 9e5d1c4..3b7d31d 100644 --- a/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java +++ b/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java @@ -1,10 +1,12 @@ package de.w665.sharepulse.rest.mappings; +import de.w665.sharepulse.rest.ro.AuthenticationRequest; import de.w665.sharepulse.service.AuthenticationService; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.HashMap; @@ -22,11 +24,13 @@ public class AuthenticationController { } @PostMapping("/login") - public ResponseEntity createAuthenticationToken() { - String token = authenticationService.authenticate("test", "test"); + public ResponseEntity createAuthenticationToken(@RequestBody AuthenticationRequest authenticationRequest) { + log.debug("Received AuthenticationRequest for username: " + authenticationRequest.getUsername()); + String token = authenticationService.authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword()); Map response = new HashMap<>(); response.put("token", token); + response.put("success", token != null); return new ResponseEntity<>(response, HttpStatus.OK); } diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java b/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java index 6dc208f..d2f9bb7 100644 --- a/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java +++ b/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java @@ -35,7 +35,7 @@ public class Upload extends ApiRestController { @RequestParam(value = "singleDownload", defaultValue = "false") boolean singleDownload, @RequestParam(value = "fileDescription", required = false) String fileDescription) { - // TODO: Handle shortStorage0 + // TODO: Handle shortStorage if (file.isEmpty()) { log.debug("User tried to upload an empty file. IP: " + request.getRemoteAddr()); @@ -53,7 +53,7 @@ public class Upload extends ApiRestController { return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - log.debug("User uploaded file " + file.getOriginalFilename() + " from IP " + request.getRemoteAddr() + " successfully."); + log.info("User uploaded file " + file.getOriginalFilename() + " from IP " + request.getRemoteAddr() + " successfully."); Map response = new HashMap<>(); response.put("fileId", fileUpload.getFileId()); diff --git a/src/main/java/de/w665/sharepulse/rest/ro/AuthenticationRequest.java b/src/main/java/de/w665/sharepulse/rest/ro/AuthenticationRequest.java new file mode 100644 index 0000000..4a77ff4 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/ro/AuthenticationRequest.java @@ -0,0 +1,15 @@ +package de.w665.sharepulse.rest.ro; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@NoArgsConstructor +@Getter +@Setter +@ToString +public class AuthenticationRequest { + private String username; + private String password; +} diff --git a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java index 2107c10..63ebabc 100644 --- a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java +++ b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java @@ -1,39 +1,51 @@ package de.w665.sharepulse.service; +import de.w665.sharepulse.db.repo.UserRepository; +import de.w665.sharepulse.model.User; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.util.Base64; import java.util.Date; +import java.util.Optional; @Slf4j @Service public class AuthenticationService { + + private final UserRepository userRepository; + @Value("${secureapi.jwt.secret}") private String secretString; - @Value("${secureapi.jwt.expiration}") private long expirationTime; // in milliseconds - private SecretKey secretKey; + private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + + public AuthenticationService(UserRepository userRepository) { + this.userRepository = userRepository; + } + @PostConstruct public void init() { - System.out.println("Initializing secret key"); + log.debug("Initializing secret key"); byte[] encodedKey = Base64.getEncoder().encode(secretString.getBytes()); // encode the secret key this.secretKey = Keys.hmacShaKeyFor(encodedKey); } public String authenticate(String username, String password) { - // validate user credentials with repository and password hash algorithm - - return generateToken(username); - // throw new RuntimeException("User authentication failed"); + Optional user = userRepository.retrieveUserByUsername(username); + if (user.isPresent() && passwordEncoder.matches(password, user.get().getPassword())) { + return generateToken(username); + } + return null; } private String generateToken(String username) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 08636ac..61997d9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,6 +5,9 @@ sharepulse.fileid.length=6 sharepulse.fileid.charset=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 sharepulse.filepassword.length=6 sharepulse.filepassword.charset=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 +sharepulse.management.user.username=admin +sharepulse.management.user.password=admin + # Database rethinkdb.host=localhost From 33d2f28222d796bad3c13f119a2ad44e380e9428 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 Apr 2024 22:44:57 +0200 Subject: [PATCH 06/35] Escaped file name enclosures --- src/main/java/de/w665/sharepulse/rest/mappings/Upload.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java b/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java index d2f9bb7..c227dea 100644 --- a/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java +++ b/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java @@ -53,7 +53,7 @@ public class Upload extends ApiRestController { return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); } - log.info("User uploaded file " + file.getOriginalFilename() + " from IP " + request.getRemoteAddr() + " successfully."); + log.info("User uploaded file \"" + file.getOriginalFilename() + "\" from IP " + request.getRemoteAddr() + " successfully."); Map response = new HashMap<>(); response.put("fileId", fileUpload.getFileId()); From 278b61323063437a9b1586b5ece8c56edba29105 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 Apr 2024 23:23:19 +0200 Subject: [PATCH 07/35] - Added error response if login info is false - Added simple token validation (DOES NOT CHECK IF TOKEN IS EXPIRED YET) - Added Softwaretest for AuthenticationService.java --- .../mappings/AuthenticationController.java | 5 ++ .../service/AuthenticationService.java | 12 +++- .../sharepulse/AuthenticationServiceTest.java | 58 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java b/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java index 3b7d31d..c94c3ad 100644 --- a/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java +++ b/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java @@ -32,6 +32,11 @@ public class AuthenticationController { response.put("token", token); response.put("success", token != null); + if(token == null) { + log.debug("Authentication failed for username: " + authenticationRequest.getUsername()); + return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); + } + return new ResponseEntity<>(response, HttpStatus.OK); } } diff --git a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java index 63ebabc..5ef2378 100644 --- a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java +++ b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java @@ -2,7 +2,7 @@ package de.w665.sharepulse.service; import de.w665.sharepulse.db.repo.UserRepository; import de.w665.sharepulse.model.User; -import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; @@ -60,4 +60,14 @@ public class AuthenticationService { .signWith(secretKey) .compact(); } + + public boolean validateToken(String token) { + try { + Jwt jwt = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); + // TODO: Check if token is expired + return true; + } catch (Exception e) { + return false; + } + } } diff --git a/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java b/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java new file mode 100644 index 0000000..36d40c2 --- /dev/null +++ b/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java @@ -0,0 +1,58 @@ +package de.w665.sharepulse; + +import de.w665.sharepulse.db.repo.UserRepository; +import de.w665.sharepulse.model.User; +import de.w665.sharepulse.service.AuthenticationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@SpringBootTest +public class AuthenticationServiceTest { + + @MockBean + private UserRepository userRepository; + + @Autowired + private AuthenticationService authenticationService; + + private User mockUser; + private String username = "testUser"; + private String password = "testPass"; + + @BeforeEach + public void setup() { + mockUser = new User(); + mockUser.setUsername(username); + mockUser.setPassword(new BCryptPasswordEncoder().encode(password)); + when(userRepository.retrieveUserByUsername(anyString())).thenReturn(Optional.of(mockUser)); + } + + @Test + public void whenValidUsernameAndPassword_thenAuthenticateShouldReturnToken() { + String token = authenticationService.authenticate(username, password); + assertNotNull(token, "Token should not be null for valid credentials"); + } + + @Test + public void whenValidToken_thenValidateTokenShouldReturnTrue() { + String token = authenticationService.authenticate(username, password); + assertTrue(authenticationService.validateToken(token), "Token validation should return true for a valid token"); + } + + @Test + public void whenInvalidToken_thenValidateTokenShouldReturnFalse() { + String invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0VXNlciJ9.WrongSignature"; + assertFalse(authenticationService.validateToken(invalidToken), "Token validation should return false for an invalid token"); + } +} From a4c71eccb13e53e2c902b88e32bccf97238a4757 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 Apr 2024 23:47:47 +0200 Subject: [PATCH 08/35] - Implemented expiration check - Added Expiration test --- .../sharepulse/service/AuthenticationService.java | 9 ++++++--- .../w665/sharepulse/AuthenticationServiceTest.java | 13 +++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java index 5ef2378..08a47b6 100644 --- a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java +++ b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java @@ -40,7 +40,10 @@ public class AuthenticationService { this.secretKey = Keys.hmacShaKeyFor(encodedKey); } - public String authenticate(String username, String password) { + public String authenticate(String username, String password, long... expirationTime/*FOR TESTING VALIDITY*/) { + if(expirationTime.length > 0) { + this.expirationTime = expirationTime[0]; + } Optional user = userRepository.retrieveUserByUsername(username); if (user.isPresent() && passwordEncoder.matches(password, user.get().getPassword())) { return generateToken(username); @@ -64,8 +67,8 @@ public class AuthenticationService { public boolean validateToken(String token) { try { Jwt jwt = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); - // TODO: Check if token is expired - return true; + Claims claims = (Claims) jwt.getPayload(); + return !claims.getExpiration().before(new Date()); // Checks if the token is expired too } catch (Exception e) { return false; } diff --git a/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java b/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java index 36d40c2..42ee008 100644 --- a/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java +++ b/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java @@ -11,6 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import java.util.Date; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -26,6 +27,9 @@ public class AuthenticationServiceTest { @Autowired private AuthenticationService authenticationService; + @Value("${secureapi.jwt.expiration}") + private long expirationTime; + private User mockUser; private String username = "testUser"; private String password = "testPass"; @@ -55,4 +59,13 @@ public class AuthenticationServiceTest { String invalidToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0VXNlciJ9.WrongSignature"; assertFalse(authenticationService.validateToken(invalidToken), "Token validation should return false for an invalid token"); } + + @Test + public void whenTokenIsExplicitlyExpired_thenValidateTokenShouldReturnFalse() throws InterruptedException { + long testExpirationTime = 1; // 1 millisecond + String token = authenticationService.authenticate("testUser", "testPass", testExpirationTime); + assertNotNull(token, "Token should not be null"); + Thread.sleep(2); // Wait for 2 milliseconds to ensure the token has expired (Bad practice but easy) + assertFalse(authenticationService.validateToken(token), "Expired token should not be valid"); + } } From c0f0467e4ae5f9a187e7099a4aea5841989b8ff2 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 7 Apr 2024 23:49:29 +0200 Subject: [PATCH 09/35] Removed unnecessary line breaks --- .../w665/sharepulse/AuthenticationServiceTest.java | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java b/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java index 42ee008..ec2b662 100644 --- a/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java +++ b/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java @@ -6,12 +6,10 @@ import de.w665.sharepulse.service.AuthenticationService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import java.util.Date; import java.util.Optional; import static org.junit.jupiter.api.Assertions.*; @@ -27,16 +25,12 @@ public class AuthenticationServiceTest { @Autowired private AuthenticationService authenticationService; - @Value("${secureapi.jwt.expiration}") - private long expirationTime; - - private User mockUser; - private String username = "testUser"; - private String password = "testPass"; + private final String username = "testUser"; + private final String password = "testPass"; @BeforeEach public void setup() { - mockUser = new User(); + User mockUser = new User(); mockUser.setUsername(username); mockUser.setPassword(new BCryptPasswordEncoder().encode(password)); when(userRepository.retrieveUserByUsername(anyString())).thenReturn(Optional.of(mockUser)); From b51250ffd3089d9491884c5317a8483eb0376b8f Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 24 Apr 2024 21:47:03 +0200 Subject: [PATCH 10/35] - Added extractSubject() and getClaimValue() methods - Updated token generator to use User object instead of username --- .../service/AuthenticationService.java | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java index 08a47b6..6df00ce 100644 --- a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java +++ b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java @@ -46,19 +46,20 @@ public class AuthenticationService { } Optional user = userRepository.retrieveUserByUsername(username); if (user.isPresent() && passwordEncoder.matches(password, user.get().getPassword())) { - return generateToken(username); + return generateToken(user.get()); } return null; } - private String generateToken(String username) { + private String generateToken(User username) { long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); Date expiryDate = new Date(nowMillis + expirationTime); return Jwts.builder() - .subject(username) + .subject(username.getUsername()) .issuedAt(now) + .claim("role", username.getRole()) .expiration(expiryDate) .signWith(secretKey) .compact(); @@ -73,4 +74,28 @@ public class AuthenticationService { return false; } } + + public String extractSubject(String token) { + return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getSubject(); + } + + /** + * Retrieves a typed claim from the JWT. + * @param token the JWT from which to extract the claim + * @param claimName the name of the claim to retrieve + * @param claimType the Class object of the type T of the claim + * @param the expected type of the claim value + * @return the value of the specified claim as type T, or null if not found or in case of an error + * Usage example: getClaimValue(token, "role", String.class) + */ + public T getClaimValue(String token, String claimName, Class claimType) { + try { + Jwt jwt = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); + Claims claims = (Claims) jwt.getPayload(); + return claims.get(claimName, claimType); + } catch (Exception e) { + log.error("Error parsing claims from token: ", e); + return null; + } + } } From c80403a4f61bd85a5a661ef81d1ab6f3f0ac3e05 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 24 Apr 2024 21:48:27 +0200 Subject: [PATCH 11/35] - Updated error response --- .../sharepulse/rest/ErrorRestController.java | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/main/java/de/w665/sharepulse/rest/ErrorRestController.java b/src/main/java/de/w665/sharepulse/rest/ErrorRestController.java index 487a54e..c15a5e1 100644 --- a/src/main/java/de/w665/sharepulse/rest/ErrorRestController.java +++ b/src/main/java/de/w665/sharepulse/rest/ErrorRestController.java @@ -1,6 +1,8 @@ package de.w665.sharepulse.rest; import org.springframework.boot.web.servlet.error.ErrorController; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -10,10 +12,19 @@ import org.springframework.web.bind.annotation.RestController; @RestController public class ErrorRestController implements ErrorController { @RequestMapping("/error") - public String handleError() { - return ""; + "" + + "

You've reached the default error page.

" + + "

This could be caused by several reasons:

" + + "
    " + + "
  • The page you are looking for does not exist.
  • " + + "
  • The page you are looking for is not accessible.
  • " + + "
  • The page you are looking for is not available at the moment.
  • " + + "
  • The page you are looking for is not accessible without authentication.
  • " + + "
"; + return new ResponseEntity<>(script, HttpStatus.FOUND); } } From 59e0fb1b480dd631832f5c8d558ec23d1f059d0a Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 24 Apr 2024 21:53:23 +0200 Subject: [PATCH 12/35] Renamed admin role --- src/main/java/de/w665/sharepulse/db/RethinkDBService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/w665/sharepulse/db/RethinkDBService.java b/src/main/java/de/w665/sharepulse/db/RethinkDBService.java index 321af66..41d4179 100644 --- a/src/main/java/de/w665/sharepulse/db/RethinkDBService.java +++ b/src/main/java/de/w665/sharepulse/db/RethinkDBService.java @@ -116,7 +116,7 @@ public class RethinkDBService { User user = new User(); user.setUsername(defaultUsername); user.setPassword(passwordEncoder.encode(defaultPassword)); - user.setRole("admin"); + user.setRole("ADMIN"); userRepository.insertUser(user); log.debug("Admin user created with default credentials. Username: admin, Password: admin"); } From 5f681a7a1bd11a246675db4bf4839e4c85f98cd7 Mon Sep 17 00:00:00 2001 From: Max Date: Sun, 19 May 2024 22:12:44 +0200 Subject: [PATCH 13/35] - Added JwtAuthenticationFilter - Added UploadHistory - Added secure endpoints to SecurityConfig --- .../sharepulse/config/SecurityConfig.java | 31 +++++--- .../rest/mappings/UploadHistory.java | 20 +++++ .../security/JwtAuthenticationFilter.java | 74 +++++++++++++++++++ .../service/AuthenticationService.java | 6 +- 4 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java create mode 100644 src/main/java/de/w665/sharepulse/rest/security/JwtAuthenticationFilter.java diff --git a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java index 75999f4..1bf48e9 100644 --- a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java +++ b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java @@ -1,32 +1,45 @@ package de.w665.sharepulse.config; +import de.w665.sharepulse.rest.security.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.Customizer; 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.LogoutConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + .csrf(csrf -> csrf.ignoringRequestMatchers("/api/v1/**")) // Disable CSRF for API routes + .sessionManagement(sessionManagement -> sessionManagement + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // No session will be created by Spring Security + ) .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/api/v1/secure/**").authenticated() - .anyRequest().permitAll() - ) - .formLogin(formLogin -> formLogin - .loginPage("/management/login") - .permitAll() + .requestMatchers("/api/v1/secure/**").authenticated() // Secure these endpoints + .anyRequest().permitAll() // All other requests are allowed without authentication ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // Apply JWT filter .logout(LogoutConfigurer::permitAll) - .csrf(csrf -> csrf - .ignoringRequestMatchers("/api/v1/**") // Disable CSRF for /api/** - ) .rememberMe(Customizer.withDefaults()); + return http.build(); } + + } + +// TODO: Fix the security configuration to allow public access to unsecured endpoints diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java new file mode 100644 index 0000000..1cbeecf --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java @@ -0,0 +1,20 @@ +package de.w665.sharepulse.rest.mappings; + +import de.w665.sharepulse.rest.SecureApiRestController; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +public class UploadHistory extends SecureApiRestController { + + @GetMapping("/test") + public ResponseEntity test(HttpServletRequest request) { + log.debug("Received test request"); + return ResponseEntity.ok("Test successful"); + } +} diff --git a/src/main/java/de/w665/sharepulse/rest/security/JwtAuthenticationFilter.java b/src/main/java/de/w665/sharepulse/rest/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..57d312f --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/security/JwtAuthenticationFilter.java @@ -0,0 +1,74 @@ +package de.w665.sharepulse.rest.security; + +import de.w665.sharepulse.service.AuthenticationService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final AuthenticationService authenticationService; + private final RequestMatcher requestMatcher = new AntPathRequestMatcher("/api/v1/secure/**"); + + @Override + protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException { + + logger.debug("Filtering request: " + request.getRequestURI()); + + if(!requestMatcher.matches(request)) { + logger.debug("Request does not match the secure path. Skipping JWT authentication."); + filterChain.doFilter(request, response); + return; + } + + try { + String jwt = getJwtFromRequest(request); + if (jwt != null && authenticationService.validateToken(jwt)) { + String username = authenticationService.extractSubject(jwt); + // Extract the role from the JWT and set it to Spring AuthenticationContext for access control + String role = authenticationService.getClaimValue(jwt, "role", String.class); + List authorities = Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + role)); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + // SUCCESSFUL AUTHENTICATION + filterChain.doFilter(request, response); + } else { + logger.warn("Unauthorized: Authentication token is missing or invalid."); + } + } catch (Exception ex) { + logger.warn("Could not set user authentication in security context. An error occurred during JWT processing.", ex); + } + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } + + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java index 6df00ce..1ec2a62 100644 --- a/src/main/java/de/w665/sharepulse/service/AuthenticationService.java +++ b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java @@ -57,9 +57,10 @@ public class AuthenticationService { Date expiryDate = new Date(nowMillis + expirationTime); return Jwts.builder() - .subject(username.getUsername()) + .subject("SharePulse Authentication Token") .issuedAt(now) .claim("role", username.getRole()) + .claim("username", username.getUsername()) .expiration(expiryDate) .signWith(secretKey) .compact(); @@ -83,8 +84,7 @@ public class AuthenticationService { * Retrieves a typed claim from the JWT. * @param token the JWT from which to extract the claim * @param claimName the name of the claim to retrieve - * @param claimType the Class object of the type T of the claim - * @param the expected type of the claim value + * @param claimType the Class object of the expected type of the claim value * @return the value of the specified claim as type T, or null if not found or in case of an error * Usage example: getClaimValue(token, "role", String.class) */ From 544523764b87bba6910985aba6ceaf5ef41b1b6d Mon Sep 17 00:00:00 2001 From: Max Date: Mon, 20 May 2024 00:31:58 +0200 Subject: [PATCH 14/35] Fixed timestamp bug --- .../de/w665/sharepulse/db/repo/FileUploadRepository.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java b/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java index d68b595..14403f6 100644 --- a/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java +++ b/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java @@ -36,7 +36,7 @@ public class FileUploadRepository { Type type = new TypeToken>(){}.getType(); Map map = gson.fromJson(json, type); - long uploadDateTimestamp = fileUpload.getUploadDate().getTime() / 1000; + long uploadDateTimestamp = fileUpload.getUploadDate().getTime(); map.put("uploadDate", uploadDateTimestamp); r.db("sharepulse").table("file_uploads").insert(map).run(connection); @@ -61,7 +61,7 @@ public class FileUploadRepository { Type type = new TypeToken>(){}.getType(); Map map = gson.fromJson(json, type); - long uploadDateTimestamp = updatedFileUpload.getUploadDate().getTime() / 1000; + long uploadDateTimestamp = updatedFileUpload.getUploadDate().getTime(); map.put("uploadDate", uploadDateTimestamp); String fileId = updatedFileUpload.getFileId(); @@ -83,11 +83,10 @@ public class FileUploadRepository { long timestamp = getOneMinuteAgoTimestamp(); - List olderFiles = r.db("sharepulse").table("file_uploads") + return r.db("sharepulse").table("file_uploads") .filter(row -> row.g("uploadDate").lt(timestamp)) .run(connection, FileUpload.class) .toList(); - return olderFiles; } private long get24HoursAgoTimestamp() { From 5df2e1a7054362464a47e83db2a1c672007d6574 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 30 May 2024 12:02:13 +0200 Subject: [PATCH 15/35] Added repository methods for fetching all entries of table - Added disabled attribute to FileUpload - Added disabled check to FileSecurityService --- .../db/repo/ExpiredFileUploadRepository.java | 14 ++++++++++++++ .../db/repo/FileUploadRepository.java | 9 ++++++++- .../de/w665/sharepulse/model/FileUpload.java | 1 + .../sharepulse/rest/mappings/UploadHistory.java | 17 +++++++++++++++++ .../sharepulse/service/FileCleanupService.java | 2 +- .../sharepulse/service/FileSecurityService.java | 3 +++ .../de/w665/sharepulse/service/FileService.java | 1 + 7 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/main/java/de/w665/sharepulse/db/repo/ExpiredFileUploadRepository.java b/src/main/java/de/w665/sharepulse/db/repo/ExpiredFileUploadRepository.java index dae75ce..d211bf3 100644 --- a/src/main/java/de/w665/sharepulse/db/repo/ExpiredFileUploadRepository.java +++ b/src/main/java/de/w665/sharepulse/db/repo/ExpiredFileUploadRepository.java @@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Repository; import java.lang.reflect.Type; +import java.util.List; import java.util.Map; @Repository @@ -38,4 +39,17 @@ public class ExpiredFileUploadRepository { r.db("sharepulse").table("expired_file_uploads").insert(map).run(connection); } + + public void deleteExpiredFileUpload(FileUpload fileUpload) { + r.db("sharepulse").table("expired_file_uploads") + .filter(r.hashMap("fileId", fileUpload.getFileId())) + .delete() + .run(connection); + } + + public List findAll() { + return r.db("sharepulse").table("expired_file_uploads") + .run(connection, FileUpload.class) + .toList(); + } } diff --git a/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java b/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java index 14403f6..27db86b 100644 --- a/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java +++ b/src/main/java/de/w665/sharepulse/db/repo/FileUploadRepository.java @@ -79,7 +79,8 @@ public class FileUploadRepository { .run(connection); } - public List getAllExpiredFileUploads() { + // This query filters all file uploads that are older than 24 hours from the file_uplaods table (not the expired_file_uploads table) + public List findAllExpiredActiveFileUploads() { long timestamp = getOneMinuteAgoTimestamp(); @@ -89,6 +90,12 @@ public class FileUploadRepository { .toList(); } + public List findAll() { + return r.db("sharepulse").table("file_uploads") + .run(connection, FileUpload.class) + .toList(); + } + private long get24HoursAgoTimestamp() { Calendar calendar = Calendar.getInstance(); calendar.add(Calendar.HOUR, -24); diff --git a/src/main/java/de/w665/sharepulse/model/FileUpload.java b/src/main/java/de/w665/sharepulse/model/FileUpload.java index 6afde28..91d88df 100644 --- a/src/main/java/de/w665/sharepulse/model/FileUpload.java +++ b/src/main/java/de/w665/sharepulse/model/FileUpload.java @@ -18,6 +18,7 @@ public class FileUpload { private String fileName; private long fileSize; private boolean singleDownload; + private boolean disabled; @JsonFormat(timezone = "ETC") private Date uploadDate; private String uploadedByIpAddress; diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java index 1cbeecf..1e540ba 100644 --- a/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java +++ b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java @@ -1,5 +1,7 @@ package de.w665.sharepulse.rest.mappings; +import de.w665.sharepulse.db.repo.FileUploadRepository; +import de.w665.sharepulse.model.FileUpload; import de.w665.sharepulse.rest.SecureApiRestController; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -8,13 +10,28 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Slf4j @RestController public class UploadHistory extends SecureApiRestController { + private final FileUploadRepository fileUploadRepository; + + public UploadHistory(FileUploadRepository fileUploadRepository) { + this.fileUploadRepository = fileUploadRepository; + } + @GetMapping("/test") public ResponseEntity test(HttpServletRequest request) { log.debug("Received test request"); return ResponseEntity.ok("Test successful"); } + + @GetMapping("/upload-history") + public ResponseEntity getUploadHistory(HttpServletRequest request, HttpServletResponse response) { + List fileUploadList = fileUploadRepository.findAll(); + log.debug("Received upload history request"); + return ResponseEntity.ok(fileUploadList); + } } diff --git a/src/main/java/de/w665/sharepulse/service/FileCleanupService.java b/src/main/java/de/w665/sharepulse/service/FileCleanupService.java index e3a3b6d..4c72f88 100644 --- a/src/main/java/de/w665/sharepulse/service/FileCleanupService.java +++ b/src/main/java/de/w665/sharepulse/service/FileCleanupService.java @@ -26,7 +26,7 @@ public class FileCleanupService { @Scheduled(cron = "0 0 * * * *") public void cleanup() { log.debug("Running cleanup..."); - List expFileUploads = fileUploadRepository.getAllExpiredFileUploads(); + List expFileUploads = fileUploadRepository.findAllExpiredActiveFileUploads(); for (FileUpload fileUpload : expFileUploads) { fileService.deleteFile(fileUpload); expiredFileUploadRepository.insertExpiredFileUpload(fileUpload); diff --git a/src/main/java/de/w665/sharepulse/service/FileSecurityService.java b/src/main/java/de/w665/sharepulse/service/FileSecurityService.java index cdb6157..b737260 100644 --- a/src/main/java/de/w665/sharepulse/service/FileSecurityService.java +++ b/src/main/java/de/w665/sharepulse/service/FileSecurityService.java @@ -21,6 +21,9 @@ public class FileSecurityService { public boolean verifyDownloadPermission(FileUpload file, String password) throws NoDownloadPermissionException { + if(file.isDisabled()) { + throw new NoDownloadPermissionException("File download has been disabled."); + } if (!file.isPasswordProtected() && !file.isSingleDownload()) { return true; diff --git a/src/main/java/de/w665/sharepulse/service/FileService.java b/src/main/java/de/w665/sharepulse/service/FileService.java index 885e66d..ef0b569 100644 --- a/src/main/java/de/w665/sharepulse/service/FileService.java +++ b/src/main/java/de/w665/sharepulse/service/FileService.java @@ -85,6 +85,7 @@ public class FileService { .fileName(file.getOriginalFilename()) .fileSize(file.getSize()) .singleDownload(singleDownload) + .disabled(false) .uploadDate(new Date()) .uploadedByIpAddress(uploaderIp) .downloadCount(0) From fd4f48a57cff9025f8e4e2af85b56c2f561c56eb Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 30 May 2024 12:10:02 +0200 Subject: [PATCH 16/35] Added UserRepository bean to SecurityConfig for Spring default auth to not log generated password. --- .../java/de/w665/sharepulse/config/SecurityConfig.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java index 1bf48e9..8bceb8d 100644 --- a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java +++ b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java @@ -8,6 +8,8 @@ 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.LogoutConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -21,6 +23,12 @@ public class SecurityConfig { this.jwtAuthenticationFilter = jwtAuthenticationFilter; } + // This bean is required for Spring Security, though it's not used in this project + @Bean + UserDetailsService emptyDetailsService() { + return username -> { throw new UsernameNotFoundException("no local users, only JWT tokens allowed"); }; + } + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http From 29a9d1707a5179598a87b2de60cc5605f7b49121 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 30 May 2024 12:13:15 +0200 Subject: [PATCH 17/35] Added UserDetailsService Bean --- src/main/java/de/w665/sharepulse/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java index 8bceb8d..de39681 100644 --- a/src/main/java/de/w665/sharepulse/config/SecurityConfig.java +++ b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java @@ -24,6 +24,7 @@ public class SecurityConfig { } // This bean is required for Spring Security, though it's not used in this project + // Prevents Spring from generating a default password @Bean UserDetailsService emptyDetailsService() { return username -> { throw new UsernameNotFoundException("no local users, only JWT tokens allowed"); }; From 414d15265568f6ffdaeb1a3982bc9bcd20778527 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 30 May 2024 12:22:18 +0200 Subject: [PATCH 18/35] Added endpoint for getting expired fileUploads --- .../w665/sharepulse/rest/mappings/UploadHistory.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java index 1e540ba..8673cd1 100644 --- a/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java +++ b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java @@ -1,5 +1,6 @@ package de.w665.sharepulse.rest.mappings; +import de.w665.sharepulse.db.repo.ExpiredFileUploadRepository; import de.w665.sharepulse.db.repo.FileUploadRepository; import de.w665.sharepulse.model.FileUpload; import de.w665.sharepulse.rest.SecureApiRestController; @@ -17,9 +18,11 @@ import java.util.List; public class UploadHistory extends SecureApiRestController { private final FileUploadRepository fileUploadRepository; + private final ExpiredFileUploadRepository expiredFileUploadRepository; - public UploadHistory(FileUploadRepository fileUploadRepository) { + public UploadHistory(FileUploadRepository fileUploadRepository, ExpiredFileUploadRepository expiredFileUploadRepository) { this.fileUploadRepository = fileUploadRepository; + this.expiredFileUploadRepository = expiredFileUploadRepository; } @GetMapping("/test") @@ -34,4 +37,11 @@ public class UploadHistory extends SecureApiRestController { log.debug("Received upload history request"); return ResponseEntity.ok(fileUploadList); } + + @GetMapping("/expired-upload-history") + public ResponseEntity getExpiredUploadHistory(HttpServletRequest request, HttpServletResponse response) { + List expiredFileUploadList = expiredFileUploadRepository.findAll(); + log.debug("Received expired upload history request"); + return ResponseEntity.ok(expiredFileUploadList); + } } From 95c9b2082af853d7ca35f2970eea4132a9d1232d Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 30 May 2024 13:44:38 +0200 Subject: [PATCH 19/35] Added login UI - Added empty adminui component - Added authStore --- .../src/app/adminui/adminui.component.html | 1 + .../src/app/adminui/adminui.component.scss | 0 .../src/app/adminui/adminui.component.spec.ts | 23 +++++++ frontend/src/app/adminui/adminui.component.ts | 12 ++++ frontend/src/app/app.routes.ts | 2 + .../src/app/download/download.component.html | 2 +- frontend/src/app/login/login.component.html | 46 ++++++++++++++ frontend/src/app/login/login.component.scss | 0 .../src/app/login/login.component.spec.ts | 23 +++++++ frontend/src/app/login/login.component.ts | 60 +++++++++++++++++++ frontend/src/store/authStore.ts | 40 +++++++++++++ 11 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/adminui/adminui.component.html create mode 100644 frontend/src/app/adminui/adminui.component.scss create mode 100644 frontend/src/app/adminui/adminui.component.spec.ts create mode 100644 frontend/src/app/adminui/adminui.component.ts create mode 100644 frontend/src/app/login/login.component.html create mode 100644 frontend/src/app/login/login.component.scss create mode 100644 frontend/src/app/login/login.component.spec.ts create mode 100644 frontend/src/app/login/login.component.ts create mode 100644 frontend/src/store/authStore.ts diff --git a/frontend/src/app/adminui/adminui.component.html b/frontend/src/app/adminui/adminui.component.html new file mode 100644 index 0000000..ac3e976 --- /dev/null +++ b/frontend/src/app/adminui/adminui.component.html @@ -0,0 +1 @@ +

adminui works!

diff --git a/frontend/src/app/adminui/adminui.component.scss b/frontend/src/app/adminui/adminui.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/adminui/adminui.component.spec.ts b/frontend/src/app/adminui/adminui.component.spec.ts new file mode 100644 index 0000000..68793fb --- /dev/null +++ b/frontend/src/app/adminui/adminui.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminuiComponent } from './adminui.component'; + +describe('AdminuiComponent', () => { + let component: AdminuiComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminuiComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AdminuiComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/adminui/adminui.component.ts b/frontend/src/app/adminui/adminui.component.ts new file mode 100644 index 0000000..948bec9 --- /dev/null +++ b/frontend/src/app/adminui/adminui.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-adminui', + standalone: true, + imports: [], + templateUrl: './adminui.component.html', + styleUrl: './adminui.component.scss' +}) +export class AdminuiComponent { + +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 4de0e46..51d840a 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -4,6 +4,7 @@ import {UploadComponent} from "./upload/upload.component"; import {DownloadComponent} from "./download/download.component"; import {CreditsComponent} from "./credits/credits.component"; import {LicensesComponent} from "./credits/licenses/licenses.component"; +import {LoginComponent} from "./login/login.component"; export const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, @@ -12,6 +13,7 @@ export const routes: Routes = [ { path: 'download', component: DownloadComponent }, { path: 'credits', component: CreditsComponent }, { path: 'licenses', component: LicensesComponent }, + { path: 'login', component: LoginComponent }, // { path: 'download/:id', component: DownloadComponent } { path: '**', redirectTo: 'home' } ]; diff --git a/frontend/src/app/download/download.component.html b/frontend/src/app/download/download.component.html index 0965040..91ea066 100644 --- a/frontend/src/app/download/download.component.html +++ b/frontend/src/app/download/download.component.html @@ -8,7 +8,7 @@ + (keydown.enter)="requestDownload()"/>
+
+

Login to SharePulse

+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+

+ Login to SharePulse to access the administrative dashboard and manage uploaded files. Please note that registration is disabled. +

+ +
+
diff --git a/frontend/src/app/login/login.component.scss b/frontend/src/app/login/login.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/login/login.component.spec.ts b/frontend/src/app/login/login.component.spec.ts new file mode 100644 index 0000000..1e19e5d --- /dev/null +++ b/frontend/src/app/login/login.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginComponent } from './login.component'; + +describe('LoginComponent', () => { + let component: LoginComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoginComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoginComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/login/login.component.ts b/frontend/src/app/login/login.component.ts new file mode 100644 index 0000000..a40d2e0 --- /dev/null +++ b/frontend/src/app/login/login.component.ts @@ -0,0 +1,60 @@ +import { Component } from '@angular/core'; +import {DevelopmentStore} from "../../store/DevelopmentStore"; +import {FormsModule} from "@angular/forms"; +import axios from "axios"; +import {NgClass, NgIf} from "@angular/common"; +import {AuthStore} from "../../store/authStore"; +import {firstValueFrom} from "rxjs"; + +@Component({ + selector: 'app-login', + standalone: true, + imports: [ + FormsModule, + NgClass, + NgIf + ], + templateUrl: './login.component.html', + styleUrl: './login.component.scss' +}) +export class LoginComponent { + inputUsername: string = ""; + inputPassword: string = ""; + loginFailed: boolean = false; + loginSuccessful: boolean = false; + + constructor(private developmentStore: DevelopmentStore, private authStore: AuthStore) { + } + + tryToLogin() { + console.log("Trying to login with username: " + this.inputUsername + " and password: " + this.inputPassword); + + axios({ + method: 'post', + url: this.developmentStore.getBaseUrl() + 'api/v1/auth/login', + headers: { + 'Content-Type': 'application/json', + }, + data: { + username: this.inputUsername, + password: this.inputPassword + } + }) + .then(async response => { + console.log(response); + console.log(response.data); + if(response.data.token) { + this.loginSuccessful = true; + this.authStore.setToken(response.data.token); + this.authStore.setUsername(this.inputUsername); + + console.log("Login successful"); + console.log("Token: " + await firstValueFrom(this.authStore.token$)); + console.log("Username: " + await firstValueFrom(this.authStore.username$)); + } + }) + .catch(error => { + this.loginFailed = true; + }); + } +} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..577ad1a --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { map } from 'rxjs/operators'; + +interface AuthStoreState { + username: string; + token: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class AuthStore { + private state: BehaviorSubject = new BehaviorSubject({ + username: "", + token: "", + }); + + // Getter for username + get username$() { + return this.state.asObservable().pipe(map(state => state.username)); + } + + // Getter for token + get token$() { + return this.state.asObservable().pipe(map(state => state.token)); + } + + // Mutation for username + setUsername(username: string) { + const currentState = this.state.getValue(); + this.state.next({ ...currentState, username }); + } + + // Mutation for token + setToken(token: string) { + const currentState = this.state.getValue(); + this.state.next({ ...currentState, token }); + } +} From 299cc565d7c5359d646cf6d6d82ae15e2129c6b7 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 30 May 2024 15:34:55 +0200 Subject: [PATCH 20/35] Added statistics - Added total file size statistic - Added total uploads statistic - Added total downloads statistic - Added icons to btns --- .../src/app/adminui/adminui.component.html | 147 +++++++++++++++++- frontend/src/app/adminui/adminui.component.ts | 79 +++++++++- frontend/src/app/app.routes.ts | 2 + frontend/src/app/login/login.component.html | 2 +- frontend/src/app/login/login.component.ts | 8 +- 5 files changed, 234 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/adminui/adminui.component.html b/frontend/src/app/adminui/adminui.component.html index ac3e976..3429e3a 100644 --- a/frontend/src/app/adminui/adminui.component.html +++ b/frontend/src/app/adminui/adminui.component.html @@ -1 +1,146 @@ -

adminui works!

+
+

Admin Dashboard

+ + +
+
+
+
Total Files Uploaded
+
{{ fileUploads.length + expiredFileUploads.length }}
+
Since launch
+
+
+ +
+
+
Total File Size on Disk
+
{{ totalFileSizeOnDisk | formatFileSizePipe }}
+
Across all files
+
+
+ +
+
+
Operational For
+
2 Years
+
Since launch
+
+
+ +
+
+
Total Downloads
+
{{ totalFileDownloads }}
+
All time
+
+
+ +
+
+
Last Admin Login
+
2 days ago
+
Most recent login
+
+
+
+ + +
+ + + + + +
+ +
+ + +

Active file uploads

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Entity IDFile IDFile NameFile SizeSingle DownloadDisabledUpload DateUploaded By IPDownload CountPassword Protected
{{ file.id }}{{ file.fileId }}{{ file.fileName }}{{ file.fileSize | formatFileSizePipe }}{{ file.singleDownload ? 'true' : 'false' }}{{ file.disabled ? 'true' : 'false' }}{{ file.uploadDate | date: 'medium' }}{{ file.uploadedByIpAddress }}{{ file.downloadCount }}{{ file.passwordProtected ? 'true' : 'false' }}
+
+ +

Expired file uploads

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Entity IDFile IDFile NameFile SizeSingle DownloadDisabledUpload DateUploaded By IPDownload CountPassword Protected
{{ file.id }}{{ file.fileId }}{{ file.fileName }}{{ file.fileSize | number }}{{ file.singleDownload ? 'true' : 'false' }}{{ file.disabled ? 'true' : 'false' }}{{ file.uploadDate | date: 'medium' }}{{ file.uploadedByIpAddress }}{{ file.downloadCount }}{{ file.passwordProtected ? 'true' : 'false' }}
+
+
diff --git a/frontend/src/app/adminui/adminui.component.ts b/frontend/src/app/adminui/adminui.component.ts index 948bec9..fd2b4b4 100644 --- a/frontend/src/app/adminui/adminui.component.ts +++ b/frontend/src/app/adminui/adminui.component.ts @@ -1,12 +1,89 @@ import { Component } from '@angular/core'; +import {DatePipe, DecimalPipe, NgForOf} from "@angular/common"; +import axios from "axios"; +import {firstValueFrom} from "rxjs"; +import {DevelopmentStore} from "../../store/DevelopmentStore"; +import {AuthStore} from "../../store/authStore"; +import {Router} from "@angular/router"; +import {FormatFileSizePipePipe} from "../format-file-size-pipe.pipe"; @Component({ selector: 'app-adminui', standalone: true, - imports: [], + imports: [ + DatePipe, + DecimalPipe, + NgForOf, + FormatFileSizePipePipe + ], templateUrl: './adminui.component.html', styleUrl: './adminui.component.scss' }) export class AdminuiComponent { + fileUploads: any[] = []; + expiredFileUploads: any[] = []; + totalFileSizeOnDisk: number = 0; + totalFileDownloads = 0; + + constructor(private developmentStore: DevelopmentStore, private authStore: AuthStore, private router: Router) { + this.verifyToken(); + } + + async verifyToken() { + if(await firstValueFrom(this.authStore.token$) === "") { + console.log("No token present, redirecting to login..."); + await this.router.navigate(['/login']); + return; + } + await this.fetchFileUploads(); + await this.fetchExpiredFileUploads(); + await this.calculateStatistics(); + } + + async calculateStatistics() { + console.log("Calculating statistics..."); + console.log(this.fileUploads) + for(let fileUpload of this.fileUploads) { + this.totalFileSizeOnDisk += fileUpload.fileSize; + } + + for(let fileUpload of this.expiredFileUploads) { + this.totalFileDownloads += fileUpload.downloadCount; + } + for(let fileUpload of this.fileUploads) { + this.totalFileDownloads += fileUpload.downloadCount; + } + } + + async fetchFileUploads() { + try { + const response = await axios({ + method: 'get', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/upload-history', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + } + }); + this.fileUploads = response.data; + } catch (error) { + console.error(error); + } + } + async fetchExpiredFileUploads() { + try { + const response = await axios({ + method: 'get', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/expired-upload-history', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + } + }); + this.expiredFileUploads = response.data; + } catch (error) { + console.error(error); + } + } } diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 51d840a..4f6ea4f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -5,6 +5,7 @@ import {DownloadComponent} from "./download/download.component"; import {CreditsComponent} from "./credits/credits.component"; import {LicensesComponent} from "./credits/licenses/licenses.component"; import {LoginComponent} from "./login/login.component"; +import {AdminuiComponent} from "./adminui/adminui.component"; export const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, @@ -14,6 +15,7 @@ export const routes: Routes = [ { path: 'credits', component: CreditsComponent }, { path: 'licenses', component: LicensesComponent }, { path: 'login', component: LoginComponent }, + { path: 'secure/administration', component: AdminuiComponent}, // { path: 'download/:id', component: DownloadComponent } { path: '**', redirectTo: 'home' } ]; diff --git a/frontend/src/app/login/login.component.html b/frontend/src/app/login/login.component.html index 3c45505..2c2cb22 100644 --- a/frontend/src/app/login/login.component.html +++ b/frontend/src/app/login/login.component.html @@ -8,7 +8,7 @@ + [ngClass]="{'input-error': loginFailed}" autofocus >