diff --git a/src/backend/settings.py b/src/backend/settings.py index c47e987e5338feaa46deb6b1e332b8ebd578ef1a..635cc31ac1537a74ae31aabaa1cc75cfcfd2dc46 100644 --- a/src/backend/settings.py +++ b/src/backend/settings.py @@ -175,7 +175,13 @@ AUTH_USER_MODEL = "users.CustomUser" REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", - ) + ), + 'DEFAULT_THROTTLE_CLASSES': [ + 'rest_framework.throttling.ScopedRateThrottle', + ], + 'DEFAULT_THROTTLE_RATES': { + 'merge': '3/hour', + } } diff --git a/src/frontend/src/app/app-routing.module.ts b/src/frontend/src/app/app-routing.module.ts index 852022c970c0a45ee5f72da28c2ae3c8b1eb0762..91126a2ca0305defe5ffd04494dfb0dd8d251773 100644 --- a/src/frontend/src/app/app-routing.module.ts +++ b/src/frontend/src/app/app-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core' import { RouterModule, Routes } from '@angular/router' import { ConsentManagementComponent } from './consent-management/consent-management..component' +import { MergeDataComponent } from './merge-data/merge-data.component' import { PageNotFoundComponent } from './page-not-found/page-not-found.component' import { LoginPageComponent } from './login-page/login-page.component' import { LegalNoticeComponent } from './legal-notice/legal-notice.component' @@ -16,6 +17,7 @@ import { AnalyticsTokensComponent } from './consent-management/analytics-tokens/ const routes: Routes = [ { path: 'home', component: HomeComponent }, { path: 'consent-management', component: ConsentManagementComponent, canActivate: [AuthGuard] }, + { path: 'merge-actors', component: MergeDataComponent, canActivate: [AuthGuard] }, { path: 'login', component: LoginPageComponent }, { path: 'provider', component: ProviderComponent, canActivate: [AuthGuard] }, { path: 'application-tokens', component: ApplicationTokensComponent, canActivate: [AuthGuard] }, diff --git a/src/frontend/src/app/app.module.ts b/src/frontend/src/app/app.module.ts index b706ca9bb46a26ac2f52e68abda6c6e838d06262..a409eca0eab3c110978c2c61b878fba024e9bb94 100644 --- a/src/frontend/src/app/app.module.ts +++ b/src/frontend/src/app/app.module.ts @@ -43,7 +43,8 @@ import { SchemaChangeComponent } from './consent-management/schema-change/schema import { ObjectChangesComponent } from './consent-management/schema-change/object-changes/object-changes.component' import { CreateTokenDialog } from './dialogs/create-token-dialog/create-token-dialog' import { MAT_MOMENT_DATE_ADAPTER_OPTIONS } from '@angular/material-moment-adapter'; -import { OrderByPipe } from './order-by.pipe' +import { OrderByPipe } from './order-by.pipe'; +import { MergeDataComponent } from './merge-data/merge-data.component' @NgModule({ declarations: [ @@ -70,7 +71,8 @@ import { OrderByPipe } from './order-by.pipe' SchemaChangeComponent, ObjectChangesComponent, CreateTokenDialog, - OrderByPipe + OrderByPipe, + MergeDataComponent ], imports: [ BrowserModule, diff --git a/src/frontend/src/app/merge-data/merge-data.component.html b/src/frontend/src/app/merge-data/merge-data.component.html new file mode 100644 index 0000000000000000000000000000000000000000..9c7fa9ae56b839e8045fca15c1af09054e25f553 --- /dev/null +++ b/src/frontend/src/app/merge-data/merge-data.component.html @@ -0,0 +1,26 @@ +<div class="content"> + <mat-card style='margin: 8px;'> + <mat-card-title i18n="Merge Data | Header entry @@mergeDataHeader"> + Merge Data + </mat-card-title> + <mat-card-content> + + <form [formGroup]="mergeForm" (ngSubmit)="submit()"> + <mat-form-field> + <mat-label>Provider</mat-label> + <mat-select formControlName="provider"> + <mat-option *ngFor="let provider of providers" [value]="provider.id">{{ provider.name }}</mat-option> + </mat-select> + </mat-form-field> + <br> + <mat-form-field> + <mat-label>TAN</mat-label> + <input matInput formControlName="tan"> + </mat-form-field> + <br> + <button type="submit" mat-raised-button i18n="Merge Data | Header entry @@mergeDataHeader">Merge Data</button> + </form> + </mat-card-content> + </mat-card> + +</div> diff --git a/src/frontend/src/app/merge-data/merge-data.component.scss b/src/frontend/src/app/merge-data/merge-data.component.scss new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/frontend/src/app/merge-data/merge-data.component.spec.ts b/src/frontend/src/app/merge-data/merge-data.component.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef7ac958657643e1392bf9ee0b016f8403b1108a --- /dev/null +++ b/src/frontend/src/app/merge-data/merge-data.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MergeDataComponent } from './merge-data.component'; + +describe('MergeDataComponent', () => { + let component: MergeDataComponent; + let fixture: ComponentFixture<MergeDataComponent>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ MergeDataComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(MergeDataComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/src/app/merge-data/merge-data.component.ts b/src/frontend/src/app/merge-data/merge-data.component.ts new file mode 100644 index 0000000000000000000000000000000000000000..770fb191da09740ad923b8c6c99f47755097ff05 --- /dev/null +++ b/src/frontend/src/app/merge-data/merge-data.component.ts @@ -0,0 +1,55 @@ +import { Component, OnInit } from '@angular/core'; +import { ApiService, Provider } from '../services/api.service' +import { AuthService, MergeResponse } from '../services/auth.service'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { ToastrService } from 'ngx-toastr' + +@Component({ + selector: 'app-merge-data', + templateUrl: './merge-data.component.html', + styleUrls: ['./merge-data.component.scss'] +}) +export class MergeDataComponent implements OnInit { + + constructor( + private _apiService: ApiService, + private _authService: AuthService, + private _toasterService: ToastrService, + ) {} + + providers: Provider[] = [] + + mergeForm: FormGroup = new FormGroup({ + provider: new FormControl('', Validators.required), + tan: new FormControl('', Validators.required), + }); + + + submit() { + if(this.mergeForm.valid) + { + this._authService.mergeData(this.mergeForm.get('provider')!.value, this.mergeForm.get('tan')!.value).subscribe((response) => { + if(response.status == "success") + { + this._toasterService.success($localize`:@@toastMessageMerged:Merged`) + } + else + { + this._toasterService.error($localize`:@@toastMessageMergeError:Error`) + } + }); + } + } + + + getProviderList(): void { + this._apiService.getProviders().subscribe((providers) => { + this.providers = providers; + }); + } + + ngOnInit(): void { + this.getProviderList(); + } + +} diff --git a/src/frontend/src/app/navigation/header/header.component.html b/src/frontend/src/app/navigation/header/header.component.html index d9a5b42833f52179386e8213b634c86ae830523e..826f220b78a8e3c3b430ebf20742a278b446e484 100644 --- a/src/frontend/src/app/navigation/header/header.component.html +++ b/src/frontend/src/app/navigation/header/header.component.html @@ -11,6 +11,14 @@ i18n="Consent Management | Header entry @@consentManagmentHeader" >Consent Management</a > + <a + mat-button + routerLink="/merge-actors" + routerLinkActive="mat-accent" + *ngIf="!loggedIn?.isProvider" + i18n="Merge Data | Header entry @@mergeDataHeader" + >Merge Data</a + > <a mat-button routerLink="/provider" diff --git a/src/frontend/src/app/services/auth.service.ts b/src/frontend/src/app/services/auth.service.ts index 265457af9a5af3aaa371f54ae9ee33a0a758a874..8543bc3a4c87b0c4d143d5c419f573592310aef8 100644 --- a/src/frontend/src/app/services/auth.service.ts +++ b/src/frontend/src/app/services/auth.service.ts @@ -20,6 +20,11 @@ interface DeleteResponse { message: string } +export interface MergeResponse { + status: string + message: string +} + interface Tokens { accessToken: any refreshToken: any @@ -80,7 +85,7 @@ export class AuthService { this._localStorage.remove('refreshToken') clearTimeout(this.refreshTokenTimeout) //this._router.navigateByUrl('/home') - + window.location.href = `${environment.apiUrl}/logout` } @@ -111,6 +116,11 @@ export class AuthService { return this._http.delete<DeleteResponse>(`${environment.apiUrl}/api/v1/auth/deleteUser`, {}) } + mergeData(provider: number, tan: string): Observable<MergeResponse> { + return this._http + .post<MergeResponse>(`${environment.apiUrl}/api/v1/auth/mergeData`, {provider: provider, tan: tan}); + } + private startRefreshTokenTimer() { const accessToken: any = jwt_decode(this.userSubject?.value?.accessToken) const expires = new Date(accessToken.exp * 1000) diff --git a/src/frontend/src/locale/messages.de.xlf b/src/frontend/src/locale/messages.de.xlf index 54ff8cd8e46985a46b80c9662cebdaf8d75f363d..f7738d190404d44240cb97b44d56ae1cab975713 100644 --- a/src/frontend/src/locale/messages.de.xlf +++ b/src/frontend/src/locale/messages.de.xlf @@ -177,6 +177,16 @@ <note priority="1" from="description"> Header entry </note> <note priority="1" from="meaning">Consent Management </note> </trans-unit> + <trans-unit id="mergeDataHeader" datatype="html"> + <source>Merge Data</source> + <target>Daten verknüpfen</target> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/navigation/header/header.component.html</context> + <context context-type="linenumber">21</context> + </context-group> + <note priority="1" from="description"> Header entry </note> + <note priority="1" from="meaning">Merge Data </note> + </trans-unit> <trans-unit id="profileProfileDropDown" datatype="html"> <source>Profile</source> <target>Profil</target> @@ -530,6 +540,14 @@ <context context-type="linenumber">20</context> </context-group> </trans-unit> + <trans-unit id="toastMessageMerged" datatype="html"> + <source> Data merged</source> + <target> Daten verknüpft</target> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/merge-data/merge-data.component.ts</context> + <context context-type="linenumber">34</context> + </context-group> + </trans-unit> <trans-unit id="dataRemovalCompleted" datatype="html"> <source>Completed</source> <source>Abgeschlossen</source> diff --git a/src/frontend/src/locale/messages.xlf b/src/frontend/src/locale/messages.xlf index af2b2dca5ac946e982dc66a6a9dbfb6af3fcdec7..9e7f4fea9fae5ea70edb2bfe6916cd06fa7ee885 100644 --- a/src/frontend/src/locale/messages.xlf +++ b/src/frontend/src/locale/messages.xlf @@ -154,6 +154,20 @@ <context context-type="linenumber">112</context> </context-group> </trans-unit> + <trans-unit id="toastMessageMerged" datatype="html"> + <source>Data merged</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/merge-data/merge-data.component.ts</context> + <context context-type="linenumber">34</context> + </context-group> + </trans-unit> + <trans-unit id="toastMessageMergeError" datatype="html"> + <source> Error. Merged 0 entries.</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/merge-data/merge-data.component.ts</context> + <context context-type="linenumber">37</context> + </context-group> + </trans-unit> <trans-unit id="pauseRecordingAndDeleteData" datatype="html"> <source>Pause and Delete Confirmation</source> <context-group purpose="location"> @@ -979,6 +993,15 @@ <note priority="1" from="description"> Header entry </note> <note priority="1" from="meaning">Consent Management </note> </trans-unit> + <trans-unit id="mergeDataHeader" datatype="html"> + <source>Merge Data</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/navigation/header/header.component.html</context> + <context context-type="linenumber">20</context> + </context-group> + <note priority="1" from="description"> Header entry </note> + <note priority="1" from="meaning">Merge Data </note> + </trans-unit> <trans-unit id="applicationTokens" datatype="html"> <source>Application Tokens</source> <context-group purpose="location"> diff --git a/src/providers/views.py b/src/providers/views.py index 0a29655b2c39f6173b5ef19a37667a7571c49e84..598e323cbbefff1f4c51ed72e9440dba406c467b 100644 --- a/src/providers/views.py +++ b/src/providers/views.py @@ -285,6 +285,7 @@ class GetProviderData(APIView): query = ( { "$and": [ + {"actor.tan": {"$exists": False}}, # important! flagged statements filtered out here {"_id": {"$gt": ObjectId(last_object_id)}}, {"verb.id": {"$in": analytics_token.active_verbs}}, ] diff --git a/src/users/urls.py b/src/users/urls.py index b6905dd2084183e9ce648fb2f77092fdbfeb6bbd..2fee4242e58ccb6db51fa3f6f311dde92282dc4b 100644 --- a/src/users/urls.py +++ b/src/users/urls.py @@ -11,4 +11,5 @@ urlpatterns = [ path("createUser", views.CreateUserView.as_view()), path("toggle-data-recording", views.ToggleDataRecording.as_view()), path("deleteUser", views.DeleteUserView.as_view()), + path("mergeData", views.MergeDataView.as_view()), ] diff --git a/src/users/views.py b/src/users/views.py index 7774c043c697a3b6c60c54b33ced8616219ca15c..f868efb08f85b2b5acd03f4f190218521fdc8572 100644 --- a/src/users/views.py +++ b/src/users/views.py @@ -12,6 +12,7 @@ from rolepermissions.checkers import has_permission from rolepermissions.roles import assign_role from backend.roles import Roles +from backend.utils import lrs_db from consents.views import JsonUploadParser from data_removal.models import DataRemovalJob @@ -113,3 +114,41 @@ class DeleteUserView(APIView): self.send_email(user) return JsonResponse({"message": "user deleted"}, status=status.HTTP_200_OK) + +class MergeDataView(APIView): + permission_classes = (IsAuthenticated,) + throttle_scope = 'merge' + + def post(self, request): + """ + Allows a user to merge data given a provider and TAN. Rate limited (see settings.py). + """ + user = request.user + + tan = request.data.get("tan", None) + provider_id = request.data.get("provider", None) + + if tan == None or provider_id == None: + return JsonResponse( + {"status": "error", "message": "invalid form data"}, status=status.HTTP_400_BAD_REQUEST + ) + + collection = lrs_db["statements"] + + query = { + "$and": [ + {"actor.tan": {"$exists": True, "$eq": tan}}, + {"actor.provider_id": {"$exists": True, "$eq": provider_id}}, + ] + } + + result = collection.update_many(query, + { + "$unset": {"actor.tan": "", "actor.provider_id": ""}, + "$set": {"actor.mbox": f'mailto:{user.email}'} + }) + + if result.modified_count > 0: + return JsonResponse({"status": "success", "message": f'{result.modified_count} records updated'}, status=status.HTTP_200_OK) + else: + return JsonResponse({"status": "error", "message": f'0 records updated'}, status=status.HTTP_400_BAD_REQUEST) diff --git a/src/xapi/urls.py b/src/xapi/urls.py index 81c189b68cf6339f9391ff1a930090f9f8e5ff82..97dbbd2a4cbb43ab0d329f06916fd62e2119f324 100644 --- a/src/xapi/urls.py +++ b/src/xapi/urls.py @@ -3,5 +3,6 @@ from django.urls import path from . import views urlpatterns = [ - path('statements', views.CreateStatement.as_view()) -] \ No newline at end of file + path('statements', views.CreateStatement.as_view()), + path('tanStatements', views.CreateTANStatement.as_view()) +] diff --git a/src/xapi/views.py b/src/xapi/views.py index cb4d590786272816911e32c33bd7a415d99a731b..55b23240efc305d2b5f1df1e46adceab3c0a1962 100644 --- a/src/xapi/views.py +++ b/src/xapi/views.py @@ -121,6 +121,34 @@ def process_statement(x_api_statement, provider, latest_schema): return {"valid": True, "accepted": True} +def process_tan_statement(x_api_statement): + """ + Process xAPI statement by checking for validation errors. + """ + try: + validate(x_api_statement, x_api_statement) + except ValidationError as e: + return {"valid": False, "reason": e.message} + + + # expects the tan to be in a field of the actor named "tan" + tan = x_api_statement.get("actor", {}).get("tan", None) + + if tan is None: + return { + "valid": False, + "reason": "TAN missing in statement", + } + + if (not "verb" in x_api_statement) or (not "id" in x_api_statement["verb"]): + return {"valid": False, "reason": "No verb given"} + verb = x_api_statement["verb"]["id"] + + if (not "object" in x_api_statement) or (not "id" in x_api_statement["object"]): + return {"valid": False, "reason": "No object given"} + + return {"valid": True} + class CreateStatement(APIView): """ @@ -208,3 +236,75 @@ class CreateStatement(APIView): safe=False, status=status.HTTP_200_OK, ) + + +class CreateTANStatement(APIView): + """ + xAPI create statements proxy for use with TANs. Stores all statements which contain TANs and are authorized with a provider token. + """ + + def post(self, request): + # provider authorization + auth_header = request.headers.get("Authorization") + if not auth_header.startswith("Basic "): + return JsonResponse( + { + "message": "No provider authorization token supplied.", + "provider": "not found", + }, + safe=False, + status=status.HTTP_401_UNAUTHORIZED, + ) + auth_key = auth_header.split(" ")[1] + try: + provider_auth = ProviderAuthorization.objects.get(key=auth_key) + except ObjectDoesNotExist: + return JsonResponse( + { + "message": "No provider for authorization token found.", + "provider": "not found", + }, + safe=False, + status=status.HTTP_400_BAD_REQUEST, + ) + + provider = provider_auth.provider + + # handle list of xAPI statements as well as single xAPI statement + x_api_statements = ( + request.data if isinstance(request.data, list) else [request.data] + ) + + result = [ + process_tan_statement(stmt) + for stmt in x_api_statements + ] + + invalid = ( + len([e for e in result if e["valid"] == False]) + > 0 + ) + + if invalid: + return JsonResponse( + { + "message": "xAPI statements couldn't be stored in LRS", + "data": result, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # store provider as well, so statements can be properly assigned when searched by users + for index, statement in enumerate(x_api_statements): + statement["actor"]["provider_id"] = provider.id + x_api_statements[index] = statement + + uuids = list(map(store_in_db, x_api_statements)) + return JsonResponse( + { + "message": "xAPI statements successfully stored in LRS", + "data": uuids, + }, + safe=False, + status=status.HTTP_200_OK, + ) diff --git a/tools/xapi-statement-generator/faker.py b/tools/xapi-statement-generator/faker.py index 044b695349eaef8bcafac97a3d3f7a9458c85519..04f33d5ef16b75ace9450c28feacd5f233d0e882 100644 --- a/tools/xapi-statement-generator/faker.py +++ b/tools/xapi-statement-generator/faker.py @@ -13,7 +13,7 @@ class XApiFaker: except IOError: print("Couldn't read provider_config.json.") - def fake_statement(self): + def fake_statement(self, tan = ""): providers = self.read_provider_config_file() provider_config = random.choice(providers) @@ -24,12 +24,17 @@ class XApiFaker: xapi_object = random.choice(verb_config["objects"]) now = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ") - actor = random.choice(provider_config["actors"]) + actor_obj = {} + if tan == "": + actor = random.choice(provider_config["actors"]) + actor_obj = {"mbox": f"mailto:{actor}"} + else: + actor_obj = {"tan": f'{tan}', "provider_id": 1} return { "token": provider_config["token"], "provider_name": provider_config["name"], "statement": { - "actor": {"mbox": f"mailto:{actor}"}, + "actor": actor_obj, "verb": xapi_verb, "object": xapi_object, "timestamp": now, diff --git a/tools/xapi-statement-generator/generator.py b/tools/xapi-statement-generator/generator.py index 5d30db2ba2e1445ff7cb746a8cc7089e713c7f4c..5f8678033b5b464bffb81872018d4270dc966520 100644 --- a/tools/xapi-statement-generator/generator.py +++ b/tools/xapi-statement-generator/generator.py @@ -102,12 +102,13 @@ def import_json(args): def start_random_generation(args): url = args.target[0] + tan = args.use_tan if args.use_tan is not None else "" print("Running...") statistic = Statistic() faker = XApiFaker() while True: try: - data = faker.fake_statement() + data = faker.fake_statement(tan) send_x_api_statement(data["token"], statistic, url, data["statement"], data["provider_name"]) time.sleep(0.2) except KeyboardInterrupt: @@ -165,6 +166,13 @@ def main(): help="Creates and sends a random xAPI statement to an LRS in an interval of 1/s.", ) + parser.add_argument( + "-u", + "--use_tan", + nargs='?', + help="Only in combination with -r: instead of a user (email), store a TAN and provider ID 1.", + ) + args = parser.parse_args() if (