diff --git a/README.md b/README.md index 1896f80..b3f689c 100644 --- a/README.md +++ b/README.md @@ -57,4 +57,7 @@ networks: volumes: rethinkdb_data: -``` \ No newline at end of file +``` + +### Dev notes +Fix slow build times: Use Gradle Build and Run settings set to IntelliJ IDEA \ No newline at end of file diff --git a/build.gradle b/build.gradle index 45eb111..a8d6cb7 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' @@ -36,6 +36,15 @@ 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' + // 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' + } bootJar { 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/frontend/src/app/adminui/adminui.component.html b/frontend/src/app/adminui/adminui.component.html new file mode 100644 index 0000000..27a5447 --- /dev/null +++ b/frontend/src/app/adminui/adminui.component.html @@ -0,0 +1,173 @@ +
+

Admin Dashboard

+ + +
+
+
+
Total Files Uploaded
+
{{ fileUploads.length + expiredFileUploads.length }}
+
Since launch
+
+
+ +
+
+
Total File Size on Disk
+
{{ totalFileSizeOnDisk | formatFileSizePipe }}
+
Across all stored files
+
+
+ +
+
+
Operational For
+
{{ statistics.applicationOnlineTime | duration }}
+
Since launch
+
+
+ +
+
+
Total Downloads
+
{{ totalFileDownloads }}
+
All time
+
+
+ +
+
+
Last Admin Login
+
{{ statistics.lastUserLogin.loginTime | relativeTime }}
+
First login...
+
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 | formatFileSizePipe }}{{ 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.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..1bc569c --- /dev/null +++ b/frontend/src/app/adminui/adminui.component.ts @@ -0,0 +1,181 @@ +import {Component, ElementRef, ViewChild} from '@angular/core'; +import {DatePipe, DecimalPipe, NgForOf, NgIf} 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"; +import {DurationPipe} from "../duration.pipe"; +import {RelativeTimePipe} from "../relative-time.pipe"; +import {FormsModule} from "@angular/forms"; +import {EdituserComponent} from "./edituser/edituser.component"; +import {LoginhistoryComponent} from "./loginhistory/loginhistory.component"; + +@Component({ + selector: 'app-adminui', + standalone: true, + imports: [ + DatePipe, + DecimalPipe, + NgForOf, + FormatFileSizePipePipe, + DurationPipe, + RelativeTimePipe, + FormsModule, + EdituserComponent, + NgIf, + LoginhistoryComponent + ], + templateUrl: './adminui.component.html', + styleUrl: './adminui.component.scss' +}) +export class AdminuiComponent { + + @ViewChild('edit_user_modal') edit_user_modal: ElementRef | undefined; + @ViewChild('login_history_modal') login_history_modal: ElementRef | undefined; + + fileUploads: any[] = []; + expiredFileUploads: any[] = []; + totalFileSizeOnDisk: number = 0; + totalFileDownloads = 0; + statistics: any = ""; + username: string = ""; + + constructor(private developmentStore: DevelopmentStore, private authStore: AuthStore, private router: Router) { + this.init(); + } + + async init() { + this.username = await firstValueFrom(this.authStore.username$); + await this.verifyToken(); + setInterval(() => { + this.verifyToken(); + }, 5000); + } + + 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.fetchStatistics(); + await this.calculateStatistics(); + } + + async calculateStatistics() { + this.totalFileSizeOnDisk = 0; + this.totalFileDownloads = 0; + + 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; + } + } + + openEditUserModal() { + this.edit_user_modal?.nativeElement.showModal(); + } + + openLoginHistoryModal() { + this.login_history_modal?.nativeElement.showModal(); + } + + logout() { + this.authStore.setToken(""); + this.authStore.setUsername(""); + this.router.navigate(['/login']); + } + + 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); + } + } + async fetchStatistics() { + try { + const response = await axios({ + method: 'get', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/statistics', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + } + }); + this.statistics = response.data; + //console.log(this.statistics) + } catch (error) { + console.error(error); + } + } + async deleteAllFileUploads() { + try { + const response = await axios({ + method: 'delete', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/files', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + } + }); + console.log(response.data) + } catch (error) { + console.error(error); + } + } + + async disableFile(fileId: string) { + try { + const response = await axios({ + method: 'put', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/files/disable', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + }, + data: { + fileId: fileId + } + }); + console.log(response.data) + } catch (error) { + console.error(error); + } + } + + protected readonly confirm = confirm; +} diff --git a/frontend/src/app/adminui/edituser/edituser.component.html b/frontend/src/app/adminui/edituser/edituser.component.html new file mode 100644 index 0000000..2dd483d --- /dev/null +++ b/frontend/src/app/adminui/edituser/edituser.component.html @@ -0,0 +1,45 @@ +
+ +
+
+
+

Edit your ({{parsedUsername}}) account details

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
diff --git a/frontend/src/app/adminui/edituser/edituser.component.scss b/frontend/src/app/adminui/edituser/edituser.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/adminui/edituser/edituser.component.spec.ts b/frontend/src/app/adminui/edituser/edituser.component.spec.ts new file mode 100644 index 0000000..3537722 --- /dev/null +++ b/frontend/src/app/adminui/edituser/edituser.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { EdituserComponent } from './edituser.component'; + +describe('EdituserComponent', () => { + let component: EdituserComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EdituserComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(EdituserComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/adminui/edituser/edituser.component.ts b/frontend/src/app/adminui/edituser/edituser.component.ts new file mode 100644 index 0000000..ba96fb7 --- /dev/null +++ b/frontend/src/app/adminui/edituser/edituser.component.ts @@ -0,0 +1,63 @@ +import {Component, Input, SimpleChanges} from '@angular/core'; +import {FormsModule} from "@angular/forms"; +import axios from "axios"; +import {firstValueFrom} from "rxjs"; +import {DevelopmentStore} from "../../../store/DevelopmentStore"; +import {AuthStore} from "../../../store/authStore"; +import {Router} from "@angular/router"; + +@Component({ + selector: 'app-edituser', + standalone: true, + imports: [ + FormsModule + ], + templateUrl: './edituser.component.html', + styleUrl: './edituser.component.scss' +}) +export class EdituserComponent { + @Input("username") parsedUsername: string = ""; + username: string = ""; + originalPassword: string = ""; + newPassword: string = ""; + confirmNewPassword: string = ""; + + constructor(private developmentStore: DevelopmentStore, private authStore: AuthStore, private router: Router) {} + + async saveUser() { + + if(this.newPassword !== this.confirmNewPassword) { + alert("New password and confirm password do not match"); + return; + } + + try { + const response = await axios({ + method: 'post', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/users', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + }, + data: { + originalUsername: this.parsedUsername, + username: this.username, + originalPassword: this.originalPassword, + newPassword: this.newPassword, + newPasswordConfirm: this.confirmNewPassword + } + }); + console.log("User updated successfully"); + alert("User updated successfully. Please log in again to continue."); + await this.router.navigate(['/login']); + } catch (error) { + console.error(error); + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['parsedUsername'] && !this.username) { + this.username = changes['parsedUsername'].currentValue; + } + } +} diff --git a/frontend/src/app/adminui/loginhistory/loginhistory.component.html b/frontend/src/app/adminui/loginhistory/loginhistory.component.html new file mode 100644 index 0000000..9b0b263 --- /dev/null +++ b/frontend/src/app/adminui/loginhistory/loginhistory.component.html @@ -0,0 +1,26 @@ +
+ +
+

Operations

+ +

Login history

+
+ + + + + + + + + + + + + + + + + +
Login TimeLogin IP
{{ entry.loginTime | date: 'dd. MMMM yyyy, HH:mm:ss' }}{{ entry.loginIp }}
+
diff --git a/frontend/src/app/adminui/loginhistory/loginhistory.component.scss b/frontend/src/app/adminui/loginhistory/loginhistory.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/adminui/loginhistory/loginhistory.component.spec.ts b/frontend/src/app/adminui/loginhistory/loginhistory.component.spec.ts new file mode 100644 index 0000000..4bd4b13 --- /dev/null +++ b/frontend/src/app/adminui/loginhistory/loginhistory.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginhistoryComponent } from './loginhistory.component'; + +describe('LoginhistoryComponent', () => { + let component: LoginhistoryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [LoginhistoryComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(LoginhistoryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/adminui/loginhistory/loginhistory.component.ts b/frontend/src/app/adminui/loginhistory/loginhistory.component.ts new file mode 100644 index 0000000..7bf22ba --- /dev/null +++ b/frontend/src/app/adminui/loginhistory/loginhistory.component.ts @@ -0,0 +1,59 @@ +import {Component, Input} from '@angular/core'; +import {DevelopmentStore} from "../../../store/DevelopmentStore"; +import {AuthStore} from "../../../store/authStore"; +import axios from "axios"; +import {firstValueFrom} from "rxjs"; +import {DatePipe, NgForOf} from "@angular/common"; + +@Component({ + selector: 'app-loginhistory', + standalone: true, + imports: [ + NgForOf, + DatePipe + ], + templateUrl: './loginhistory.component.html', + styleUrl: './loginhistory.component.scss' +}) +export class LoginhistoryComponent { + @Input() username: string = ""; + loginHistory: any[] = []; + + constructor(private developmentStore: DevelopmentStore, private authStore: AuthStore) { + this.fetchUserLoginHistory(); + } + + async fetchUserLoginHistory() { + try { + const response = await axios({ + method: 'get', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/loginhistory', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + } + }); + this.loginHistory = response.data; + console.log(this.loginHistory) + } catch (error) { + console.error(error); + } + } + + async deleteLogins() { + try { + const response = await axios({ + method: 'delete', + url: this.developmentStore.getBaseUrl() + 'api/v1/secure/loginhistory', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + await firstValueFrom(this.authStore.token$) + } + }); + console.log(response.data); + this.fetchUserLoginHistory(); + } catch (error) { + console.error(error); + } + } +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 4de0e46..4f6ea4f 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -4,6 +4,8 @@ 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"; +import {AdminuiComponent} from "./adminui/adminui.component"; export const routes: Routes = [ { path: '', redirectTo: 'home', pathMatch: 'full' }, @@ -12,6 +14,8 @@ export const routes: Routes = [ { path: 'download', component: DownloadComponent }, { 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/credits/credits.component.html b/frontend/src/app/credits/credits.component.html index c29454e..ca61549 100644 --- a/frontend/src/app/credits/credits.component.html +++ b/frontend/src/app/credits/credits.component.html @@ -42,9 +42,15 @@ + +

Privacy Policy | Terms of Use

+
+ +

© 2024 SharePulse. All rights reserved.

+
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.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()"/>
{ @@ -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/duration.pipe.spec.ts b/frontend/src/app/duration.pipe.spec.ts new file mode 100644 index 0000000..e38345d --- /dev/null +++ b/frontend/src/app/duration.pipe.spec.ts @@ -0,0 +1,8 @@ +import { DurationPipe } from './duration.pipe'; + +describe('DurationPipe', () => { + it('create an instance', () => { + const pipe = new DurationPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/duration.pipe.ts b/frontend/src/app/duration.pipe.ts new file mode 100644 index 0000000..f94ee9a --- /dev/null +++ b/frontend/src/app/duration.pipe.ts @@ -0,0 +1,29 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'duration', + standalone: true +}) +export class DurationPipe implements PipeTransform { + + transform(value: number): string { + if (!value) { + return '0m'; + } + + let milliseconds = value; + const days = Math.floor(milliseconds / (24 * 60 * 60 * 1000)); + milliseconds %= 24 * 60 * 60 * 1000; + const hours = Math.floor(milliseconds / (60 * 60 * 1000)); + milliseconds %= 60 * 60 * 1000; + const minutes = Math.floor(milliseconds / (60 * 1000)); + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } else if (hours > 0) { + return `${hours}h ${minutes}m`; + } else { + return `${minutes}m`; + } + } +} diff --git a/frontend/src/app/login/login.component.html b/frontend/src/app/login/login.component.html new file mode 100644 index 0000000..2c5ff3f --- /dev/null +++ b/frontend/src/app/login/login.component.html @@ -0,0 +1,46 @@ +
+
+

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..1c1ead5 --- /dev/null +++ b/frontend/src/app/login/login.component.ts @@ -0,0 +1,62 @@ +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"; +import {Router} from "@angular/router"; + +@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, private router: Router) { + } + + 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); + + //timeout + setTimeout(() => { + this.router.navigate(['/secure/administration']); + }, 500); + } + }) + .catch(error => { + this.loginFailed = true; + }); + } +} diff --git a/frontend/src/app/relative-time.pipe.spec.ts b/frontend/src/app/relative-time.pipe.spec.ts new file mode 100644 index 0000000..ba62c09 --- /dev/null +++ b/frontend/src/app/relative-time.pipe.spec.ts @@ -0,0 +1,8 @@ +import { RelativeTimePipe } from './relative-time.pipe'; + +describe('RelativeTimePipe', () => { + it('create an instance', () => { + const pipe = new RelativeTimePipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/relative-time.pipe.ts b/frontend/src/app/relative-time.pipe.ts new file mode 100644 index 0000000..9016478 --- /dev/null +++ b/frontend/src/app/relative-time.pipe.ts @@ -0,0 +1,45 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'relativeTime', + standalone: true, + pure: false +}) +export class RelativeTimePipe implements PipeTransform { + + transform(value: string): string { + if (!value) { + return ''; + } + + const now = new Date(); + const past = new Date(value); + const elapsed = now.getTime() - past.getTime(); + + const msPerSecond = 1000; + const msPerMinute = msPerSecond * 60; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerWeek = msPerDay * 7; + const msPerMonth = msPerDay * 30; + const msPerYear = msPerDay * 365; + + if (elapsed < msPerSecond) { + return 'just now'; + } else if (elapsed < msPerMinute) { + return `${Math.round(elapsed / msPerSecond)} seconds ago`; + } else if (elapsed < msPerHour) { + return `${Math.round(elapsed / msPerMinute)} minutes ago`; + } else if (elapsed < msPerDay) { + return `${Math.round(elapsed / msPerHour)} hours ago`; + } else if (elapsed < msPerWeek) { + return `${Math.round(elapsed / msPerDay)} days ago`; + } else if (elapsed < msPerMonth) { + return `${Math.round(elapsed / msPerWeek)} weeks ago`; + } else if (elapsed < msPerYear) { + return `${Math.round(elapsed / msPerMonth)} months ago`; + } else { + return `${Math.round(elapsed / msPerYear)} years ago`; + } + } +} 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/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 }); + } +} diff --git a/src/main/java/de/w665/sharepulse/SharepulseApplication.java b/src/main/java/de/w665/sharepulse/SharepulseApplication.java index 2c4ac58..2f2af61 100644 --- a/src/main/java/de/w665/sharepulse/SharepulseApplication.java +++ b/src/main/java/de/w665/sharepulse/SharepulseApplication.java @@ -3,10 +3,15 @@ package de.w665.sharepulse; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import java.util.Date; + @SpringBootApplication public class SharepulseApplication { + public static Date startTime; + public static void main(String[] args) { + startTime = new Date(); SpringApplication.run(SharepulseApplication.class, args); } diff --git a/src/main/java/de/w665/sharepulse/config/CorsConfig.java b/src/main/java/de/w665/sharepulse/config/CorsConfig.java index 37f5971..09468b7 100644 --- a/src/main/java/de/w665/sharepulse/config/CorsConfig.java +++ b/src/main/java/de/w665/sharepulse/config/CorsConfig.java @@ -17,6 +17,7 @@ public class CorsConfig implements WebMvcConfigurer { registry.addMapping("/api/v1/**") .allowedOrigins("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") - .allowedHeaders("*"); + .allowedHeaders("*") + .maxAge(3600); } } 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/MvcConfig.java b/src/main/java/de/w665/sharepulse/config/MvcConfig.java index 7e1d869..3e3aac3 100644 --- a/src/main/java/de/w665/sharepulse/config/MvcConfig.java +++ b/src/main/java/de/w665/sharepulse/config/MvcConfig.java @@ -16,6 +16,8 @@ public class MvcConfig implements WebMvcConfigurer { registry.addViewController("/upload").setViewName("forward:/index.html"); registry.addViewController("/credits").setViewName("forward:/index.html"); registry.addViewController("/licenses").setViewName("forward:/index.html"); + registry.addViewController("/login").setViewName("forward:/index.html"); + registry.addViewController("/secure/administration").setViewName("forward:/index.html"); } } 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..de39681 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/config/SecurityConfig.java @@ -0,0 +1,54 @@ +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.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +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; + } + + // 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"); }; + } + + @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() // Secure these endpoints + .anyRequest().permitAll() // All other requests are allowed without authentication + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // Apply JWT filter + .logout(LogoutConfigurer::permitAll) + .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/db/RethinkDBService.java b/src/main/java/de/w665/sharepulse/db/RethinkDBService.java index da1a8d0..b065186 100644 --- a/src/main/java/de/w665/sharepulse/db/RethinkDBService.java +++ b/src/main/java/de/w665/sharepulse/db/RethinkDBService.java @@ -3,33 +3,44 @@ 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 +92,59 @@ 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."); + } + } + + // rethinkdb check if table user_logins exists + try { + r.db(config.getDatabase()).tableCreate("user_logins").run(connection).stream(); + log.debug("Table 'user_logins' created successfully."); + } catch (ReqlOpFailedError e) { + log.debug("Table 'user_logins' already exists."); + if(autoResetOnStartup) { + log.debug("Clearing content..."); + r.db(config.getDatabase()).table("user_logins").delete().run(connection); + log.debug("Table 'user_logins' cleared successfully."); + } + } finally { + try { + r.db(config.getDatabase()).table("user_logins").indexCreate("loginTime").run(connection); + log.debug("Secondary index 'loginTime' on table 'user_logins' successfully created."); + } catch (ReqlOpFailedError e) { + log.debug("Secondary index 'loginTime' already exists."); + } finally { + r.db(config.getDatabase()).table("user_logins").indexWait("loginTime").run(connection); + } + } + + 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/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 d68b595..27db86b 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(); @@ -79,15 +79,21 @@ 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(); - 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; + } + + public List findAll() { + return r.db("sharepulse").table("file_uploads") + .run(connection, FileUpload.class) + .toList(); } private long get24HoursAgoTimestamp() { diff --git a/src/main/java/de/w665/sharepulse/db/repo/UserLoginRepository.java b/src/main/java/de/w665/sharepulse/db/repo/UserLoginRepository.java new file mode 100644 index 0000000..97f111c --- /dev/null +++ b/src/main/java/de/w665/sharepulse/db/repo/UserLoginRepository.java @@ -0,0 +1,67 @@ +package de.w665.sharepulse.db.repo; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.gson.Gson; +import com.rethinkdb.RethinkDB; +import com.rethinkdb.net.Connection; +import com.rethinkdb.net.Result; +import de.w665.sharepulse.db.RethinkDBConfig; +import de.w665.sharepulse.db.RethinkDBConnector; +import de.w665.sharepulse.model.UserLogin; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Slf4j +@Repository +public class UserLoginRepository { + private final RethinkDB r; + private final Connection connection; + private final RethinkDBConfig config; + private final String TABLE_NAME = "user_logins"; + + private final Gson gson = new Gson(); + private final ObjectMapper mapper = new ObjectMapper(); + + + @Autowired + public UserLoginRepository(RethinkDBConnector connector, RethinkDBConfig config) { + this.r = connector.getR(); + this.connection = connector.getConnection(); + this.config = config; + } + + public void insertUserLogin(UserLogin userLogin) { + String uuid = r.uuid().run(connection, String.class).first(); + userLogin.setId(uuid); + r.db(config.getDatabase()).table(TABLE_NAME).insert(userLogin).run(connection); + } + + public UserLogin getLastLogin(String userId) { + // Get the second most recent login (the most recent is the current one) + Result result = r.db(config.getDatabase()).table(TABLE_NAME) + .orderBy().optArg("index", r.desc("loginTime")) + .filter(r.hashMap("userId", userId)) + .skip(1).limit(1) + .run(connection, UserLogin.class); + // Return the second most recent login if exists + return result.hasNext() ? result.next() : null; + } + + public List getUserLogins(String userId) { + Result result = r.db(config.getDatabase()).table(TABLE_NAME) + .orderBy().optArg("index", r.desc("loginTime")) + .filter(r.hashMap("userId", userId)) + .run(connection, UserLogin.class); + return result.toList(); + } + + public void deleteAllUserLogins(String userId) { + r.db(config.getDatabase()).table(TABLE_NAME) + .filter(r.hashMap("userId", userId)) + .delete() + .run(connection); + } +} 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..bad97c4 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/db/repo/UserRepository.java @@ -0,0 +1,55 @@ +package de.w665.sharepulse.db.repo; + +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 updateLastLoginForUser(String username, Date lastLogin) { + r.db(config.getDatabase()).table("users") + .filter(r.hashMap("username", username)) + .update(r.hashMap("lastLogin", lastLogin.getTime())) + .run(connection); + } + + public void updateUser(User user) { + r.db(config.getDatabase()).table("users") + .filter(r.hashMap("id", user.getId())) + .update(user) + .run(connection); + } + + public void insertUser(User user) { + String optionalUuid = r.uuid().run(connection, String.class).first(); + user.setId(optionalUuid); + r.db(config.getDatabase()).table("users").insert(user).run(connection); + } +} 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/model/User.java b/src/main/java/de/w665/sharepulse/model/User.java new file mode 100644 index 0000000..0bb9576 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/model/User.java @@ -0,0 +1,19 @@ +package de.w665.sharepulse.model; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.*; + +import java.util.Date; + +@Getter +@Setter +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class User { + private String id; // ID is auto mapped by RethinkDB + private String username; + private String password; + private String email; + private String role; +} diff --git a/src/main/java/de/w665/sharepulse/model/UserLogin.java b/src/main/java/de/w665/sharepulse/model/UserLogin.java new file mode 100644 index 0000000..854f6cc --- /dev/null +++ b/src/main/java/de/w665/sharepulse/model/UserLogin.java @@ -0,0 +1,21 @@ +package de.w665.sharepulse.model; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class UserLogin { + String id; + String userId; + @JsonFormat(timezone = "ETC") + Date loginTime; + String loginIp; +} 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 { } 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); } } 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/Administration.java b/src/main/java/de/w665/sharepulse/rest/mappings/Administration.java new file mode 100644 index 0000000..68b0e89 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/mappings/Administration.java @@ -0,0 +1,126 @@ +package de.w665.sharepulse.rest.mappings; + +import de.w665.sharepulse.SharepulseApplication; +import de.w665.sharepulse.db.repo.FileUploadRepository; +import de.w665.sharepulse.db.repo.UserLoginRepository; +import de.w665.sharepulse.db.repo.UserRepository; +import de.w665.sharepulse.model.FileUpload; +import de.w665.sharepulse.model.User; +import de.w665.sharepulse.rest.SecureApiRestController; +import de.w665.sharepulse.rest.ro.FileDeleteRequest; +import de.w665.sharepulse.rest.ro.UserEditRequest; +import de.w665.sharepulse.service.AuthenticationService; +import de.w665.sharepulse.service.FileCleanupService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@RestController +public class Administration extends SecureApiRestController { + + private final UserRepository userRepository; + private final AuthenticationService authenticationService; + private final FileCleanupService fileCleanupService; + private final UserLoginRepository userLoginRepository; + private final FileUploadRepository fileUploadRepository; + + public Administration(UserRepository userRepository, AuthenticationService authenticationService, FileCleanupService fileCleanupService, UserLoginRepository userLoginRepository, FileUploadRepository fileUploadRepository) { + this.userRepository = userRepository; + this.authenticationService = authenticationService; + this.fileCleanupService = fileCleanupService; + this.userLoginRepository = userLoginRepository; + this.fileUploadRepository = fileUploadRepository; + } + + @GetMapping("/statistics") + public ResponseEntity getStatistics(HttpServletRequest request) { + // TODO: FIX LAST LOGIN + + String token = request.getHeader("Authorization"); + token = token.substring(7); + String username = authenticationService.getClaimValue(token, "username", String.class); + Optional optionalUser = userRepository.retrieveUserByUsername(username); + if(optionalUser.isEmpty()) { + return ResponseEntity.badRequest().body("User not found"); + } + User user = optionalUser.get(); + + // If role filtering is required later, add here + + Map response = new HashMap<>(); + response.put("applicationOnlineTime", System.currentTimeMillis() - SharepulseApplication.startTime.getTime()); + response.put("lastUserLogin", userLoginRepository.getLastLogin(user.getId())); + return ResponseEntity.ok(response); + } + + @PostMapping("/users") + public ResponseEntity updateUser(@RequestBody UserEditRequest userEditRequest, HttpServletRequest request) { + Optional optionalUser = userRepository.retrieveUserByUsername(userEditRequest.getOriginalUsername()); + if(optionalUser.isEmpty()) { + return ResponseEntity.badRequest().body("User not found"); + } else if (!userEditRequest.getNewPassword().equals(userEditRequest.getNewPasswordConfirm())) { + return ResponseEntity.badRequest().body("Passwords do not match"); + } else if(userEditRequest.getNewPassword().length() < 4) { + return ResponseEntity.badRequest().body("Password too short. Must be at least 4 characters."); + } + User user = optionalUser.get(); + user.setPassword(authenticationService.encodePassword(userEditRequest.getNewPassword())); + user.setUsername(userEditRequest.getUsername()); + userRepository.updateUser(user); + // Clear password before returning + user.setPassword(null); + return ResponseEntity.ok(user); + } + + @GetMapping("/loginhistory") + public ResponseEntity getLoginHistory(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + token = token.substring(7); + String username = authenticationService.getClaimValue(token, "username", String.class); + Optional optionalUser = userRepository.retrieveUserByUsername(username); + if(optionalUser.isEmpty()) { + return ResponseEntity.badRequest().body("User not found"); + } + String userId = optionalUser.get().getId(); + return ResponseEntity.ok(userLoginRepository.getUserLogins(userId)); + } + + @DeleteMapping("/loginhistory") + public ResponseEntity deleteLoginHistory(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + token = token.substring(7); + String username = authenticationService.getClaimValue(token, "username", String.class); + Optional optionalUser = userRepository.retrieveUserByUsername(username); + if(optionalUser.isEmpty()) { + return ResponseEntity.badRequest().body("User not found"); + } + String userId = optionalUser.get().getId(); + userLoginRepository.deleteAllUserLogins(userId); + return ResponseEntity.ok("User logins deleted successfully!"); + } + + @DeleteMapping("/files") + public ResponseEntity deleteFiles(HttpServletRequest request) { + List files = fileCleanupService.deleteFiles(); + return ResponseEntity.ok(files); + } + + @PutMapping("/files/disable") + public ResponseEntity disableFileUploads(@RequestBody FileDeleteRequest fdr, HttpServletRequest request) { + Optional optionalFileUpload = fileUploadRepository.retrieveFileUploadByFileId(fdr.getFileId()); + if(optionalFileUpload.isEmpty()) { + return ResponseEntity.badRequest().body("File not found"); + } + FileUpload fileUpload = optionalFileUpload.get(); + fileUpload.setDisabled(true); + fileUploadRepository.updateFileUpload(fileUpload); + return ResponseEntity.ok("File " + fdr.getFileId() + " disabled successfully."); + } +} 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..0eaacb7 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/mappings/AuthenticationController.java @@ -0,0 +1,43 @@ +package de.w665.sharepulse.rest.mappings; + +import de.w665.sharepulse.rest.ro.AuthenticationRequest; +import de.w665.sharepulse.service.AuthenticationService; +import jakarta.servlet.http.HttpServletRequest; +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; +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(@RequestBody AuthenticationRequest authenticationRequest, HttpServletRequest request) { + log.debug("Received AuthenticationRequest for username: " + authenticationRequest.getUsername()); + String token = authenticationService.authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword(), request.getRemoteAddr()); + + Map response = new HashMap<>(); + 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/rest/mappings/Upload.java b/src/main/java/de/w665/sharepulse/rest/mappings/Upload.java index 6dc208f..c227dea 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/mappings/UploadHistory.java b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java new file mode 100644 index 0000000..8673cd1 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/mappings/UploadHistory.java @@ -0,0 +1,47 @@ +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; +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; + +import java.util.List; + +@Slf4j +@RestController +public class UploadHistory extends SecureApiRestController { + + private final FileUploadRepository fileUploadRepository; + private final ExpiredFileUploadRepository expiredFileUploadRepository; + + public UploadHistory(FileUploadRepository fileUploadRepository, ExpiredFileUploadRepository expiredFileUploadRepository) { + this.fileUploadRepository = fileUploadRepository; + this.expiredFileUploadRepository = expiredFileUploadRepository; + } + + @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); + } + + @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); + } +} 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/rest/ro/FileDeleteRequest.java b/src/main/java/de/w665/sharepulse/rest/ro/FileDeleteRequest.java new file mode 100644 index 0000000..ff6fe18 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/ro/FileDeleteRequest.java @@ -0,0 +1,14 @@ +package de.w665.sharepulse.rest.ro; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +@NoArgsConstructor +@Getter +@Setter +@ToString +public class FileDeleteRequest { + private String fileId; +} diff --git a/src/main/java/de/w665/sharepulse/rest/ro/UserEditRequest.java b/src/main/java/de/w665/sharepulse/rest/ro/UserEditRequest.java new file mode 100644 index 0000000..7e2838c --- /dev/null +++ b/src/main/java/de/w665/sharepulse/rest/ro/UserEditRequest.java @@ -0,0 +1,16 @@ +package de.w665.sharepulse.rest.ro; + +import lombok.*; + +@NoArgsConstructor +@Getter +@Setter +@ToString +public class UserEditRequest { + private String originalUsername; + private String username; + private String originalPassword; + private String newPassword; + private String newPasswordConfirm; + private String email; // E-Mail is not implemented into frontend yet +} 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 new file mode 100644 index 0000000..6a39d09 --- /dev/null +++ b/src/main/java/de/w665/sharepulse/service/AuthenticationService.java @@ -0,0 +1,112 @@ +package de.w665.sharepulse.service; + +import de.w665.sharepulse.db.repo.UserLoginRepository; +import de.w665.sharepulse.db.repo.UserRepository; +import de.w665.sharepulse.model.User; +import de.w665.sharepulse.model.UserLogin; +import io.jsonwebtoken.*; +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; + private final UserLoginRepository userLoginRepository; + + @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, UserLoginRepository userLoginRepository) { + this.userRepository = userRepository; + this.userLoginRepository = userLoginRepository; + } + + @PostConstruct + public void init() { + 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, String remoteAddr, long... expirationTime/*FOR TESTING VALIDITY*/) { + if(expirationTime.length > 0) { + this.expirationTime = expirationTime[0]; + } + Optional optionalUser = userRepository.retrieveUserByUsername(username); + if (optionalUser.isPresent() && passwordEncoder.matches(password, optionalUser.get().getPassword())) { + User user = optionalUser.get(); + + userLoginRepository.insertUserLogin(new UserLogin(""/*Auto generated*/, user.getId(), new Date(), remoteAddr)); + userRepository.updateLastLoginForUser(user.getUsername(), new Date()); + return generateToken(user); + } + return null; + } + + private String generateToken(User username) { + long nowMillis = System.currentTimeMillis(); + Date now = new Date(nowMillis); + Date expiryDate = new Date(nowMillis + expirationTime); + + return Jwts.builder() + .subject("SharePulse Authentication Token") + .issuedAt(now) + .claim("role", username.getRole()) + .claim("username", username.getUsername()) + .expiration(expiryDate) + .signWith(secretKey) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwt jwt = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token); + Claims claims = (Claims) jwt.getPayload(); + return !claims.getExpiration().before(new Date()); // Checks if the token is expired too + } catch (Exception e) { + 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 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; + } + } + + public String encodePassword(String password) { + return passwordEncoder.encode(password); + } +} diff --git a/src/main/java/de/w665/sharepulse/service/FileCleanupService.java b/src/main/java/de/w665/sharepulse/service/FileCleanupService.java index e3a3b6d..091da8c 100644 --- a/src/main/java/de/w665/sharepulse/service/FileCleanupService.java +++ b/src/main/java/de/w665/sharepulse/service/FileCleanupService.java @@ -26,11 +26,22 @@ 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); log.debug("Moved file " + fileUpload.getFileId() + " to old_file_uploads table."); } } + + public List deleteFiles() { + log.debug("Running cleanup. Clearing all files..."); + List fileUploads = fileUploadRepository.findAll(); + for (FileUpload fileUpload : fileUploads) { + fileService.deleteFile(fileUpload); + expiredFileUploadRepository.insertExpiredFileUpload(fileUpload); + log.debug("Moved file " + fileUpload.getFileId() + " to old_file_uploads table."); + } + return fileUploads; + } } 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) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3e5ad1a..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 @@ -25,4 +28,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 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..75ffeb4 --- /dev/null +++ b/src/test/java/de/w665/sharepulse/AuthenticationServiceTest.java @@ -0,0 +1,65 @@ +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.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 final String username = "testUser"; + private final String password = "testPass"; + + @BeforeEach + public void setup() { + User 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"); + } + + @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"); + } +}