diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ee8c4d685f..68c1bce6f3 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -48,8 +48,8 @@ import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; import { getDefaultThemeConfig } from '../config/config.util'; -import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; -import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; +import { AppConfig, APP_CONFIG } from '../config/app-config.interface'; +import { GoogleRecaptchaService } from './core/google-recaptcha/google-recaptcha.service'; @Component({ selector: 'ds-app', @@ -110,6 +110,7 @@ export class AppComponent implements OnInit, AfterViewInit { private modalConfig: NgbModalConfig, @Optional() private cookiesService: KlaroService, @Optional() private googleAnalyticsService: GoogleAnalyticsService, + @Optional() private googleRecaptchaService: GoogleRecaptchaService, ) { if (!isEqual(environment, this.appConfig)) { diff --git a/src/app/core/google-recaptcha/google-recaptcha.module.ts b/src/app/core/google-recaptcha/google-recaptcha.module.ts index e2acba0e93..8af9adb641 100644 --- a/src/app/core/google-recaptcha/google-recaptcha.module.ts +++ b/src/app/core/google-recaptcha/google-recaptcha.module.ts @@ -1,17 +1,23 @@ +import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { GoogleRecaptchaComponent } from '../../shared/google-recaptcha/google-recaptcha.component'; import { SharedModule } from '../../shared/shared.module'; import { GoogleRecaptchaService } from './google-recaptcha.service'; const PROVIDERS = [ - GoogleRecaptchaService + GoogleRecaptchaService +]; + +const COMPONENTS = [ + GoogleRecaptchaComponent ]; @NgModule({ - imports: [ SharedModule ], - providers: [ - ...PROVIDERS - ] + imports: [ CommonModule ], + providers: [...PROVIDERS], + declarations: [...COMPONENTS], + exports: [...COMPONENTS] }) /** diff --git a/src/app/core/google-recaptcha/google-recaptcha.service.ts b/src/app/core/google-recaptcha/google-recaptcha.service.ts index 46611e5ebb..99311247f6 100644 --- a/src/app/core/google-recaptcha/google-recaptcha.service.ts +++ b/src/app/core/google-recaptcha/google-recaptcha.service.ts @@ -5,8 +5,10 @@ import { isNotEmpty } from '../../shared/empty.util'; import { DOCUMENT } from '@angular/common'; import { ConfigurationDataService } from '../data/configuration-data.service'; import { RemoteData } from '../data/remote-data'; -import { map } from 'rxjs/operators'; -import { combineLatest } from 'rxjs'; +import { map, take } from 'rxjs/operators'; +import { combineLatest, Observable, of } from 'rxjs'; + +export const CAPTCHA_COOKIE = '_GRECAPTCHA'; /** * A GoogleRecaptchaService used to send action and get a token from REST @@ -18,7 +20,22 @@ export class GoogleRecaptchaService { /** * A Google Recaptcha site key */ - captchaSiteKey: string; + captchaSiteKeyStr: string; + + /** + * A Google Recaptcha site key + */ + captchaSiteKey$: Observable; + + /** + * A Google Recaptcha mode + */ + captchaMode$: Observable = of('invisible'); + + /** + * A Google Recaptcha version + */ + captchaVersion$: Observable; constructor( @Inject(DOCUMENT) private _document: Document, @@ -27,19 +44,43 @@ export class GoogleRecaptchaService { ) { this.renderer = rendererFactory.createRenderer(null, null); const registrationVerification$ = this.configService.findByPropertyName('registration.verification.enabled').pipe( + take(1), getFirstCompletedRemoteData(), map((res: RemoteData) => { return res.hasSucceeded && res.payload && isNotEmpty(res.payload.values) && res.payload.values[0].toLowerCase() === 'true'; }) ); const recaptchaKey$ = this.configService.findByPropertyName('google.recaptcha.key.site').pipe( + take(1), getFirstCompletedRemoteData(), ); - combineLatest(registrationVerification$, recaptchaKey$).subscribe(([registrationVerification, recaptchaKey]) => { + const recaptchaVersion$ = this.configService.findByPropertyName('google.recaptcha.version').pipe( + take(1), + getFirstCompletedRemoteData(), + ); + const recaptchaMode$ = this.configService.findByPropertyName('google.recaptcha.mode').pipe( + take(1), + getFirstCompletedRemoteData(), + ); + combineLatest(registrationVerification$, recaptchaVersion$, recaptchaMode$, recaptchaKey$).subscribe(([registrationVerification, recaptchaVersion, recaptchaMode, recaptchaKey]) => { if (registrationVerification) { if (recaptchaKey.hasSucceeded && isNotEmpty(recaptchaKey?.payload?.values[0])) { - this.captchaSiteKey = recaptchaKey?.payload?.values[0]; - this.loadScript(this.buildCaptchaUrl(recaptchaKey?.payload?.values[0])); + this.captchaSiteKeyStr = recaptchaKey?.payload?.values[0]; + this.captchaSiteKey$ = of(recaptchaKey?.payload?.values[0]); + } + + if (recaptchaVersion.hasSucceeded && isNotEmpty(recaptchaVersion?.payload?.values[0]) && recaptchaVersion?.payload?.values[0] === 'v3') { + this.captchaVersion$ = of('v3'); + if (recaptchaKey.hasSucceeded && isNotEmpty(recaptchaKey?.payload?.values[0])) { + this.loadScript(this.buildCaptchaUrl(recaptchaKey?.payload?.values[0])); + } + } else { + this.captchaVersion$ = of('v2'); + const captchaUrl = 'https://www.google.com/recaptcha/api.js'; + if (recaptchaMode.hasSucceeded && isNotEmpty(recaptchaMode?.payload?.values[0])) { + this.captchaMode$ = of(recaptchaMode?.payload?.values[0]); + this.loadScript(captchaUrl); + } } } }); @@ -50,7 +91,22 @@ export class GoogleRecaptchaService { * @param action action is the process type in which used to protect multiple spam REST calls */ public async getRecaptchaToken (action) { - return await grecaptcha.execute(this.captchaSiteKey, {action: action}); + return await grecaptcha.execute(this.captchaSiteKeyStr, {action: action}); + } + + /** + * Returns an observable of string + */ + public async executeRecaptcha () { + return await grecaptcha.execute(); + } + + /** + * Returns an observable of string + * @param action action is the process type in which used to protect multiple spam REST calls + */ + public async getRecaptchaTokenResponse () { + return await grecaptcha.getResponse(); } /** diff --git a/src/app/register-email-form/register-email-form.component.html b/src/app/register-email-form/register-email-form.component.html index e47eedb6ae..5e87d2bd42 100644 --- a/src/app/register-email-form/register-email-form.component.html +++ b/src/app/register-email-form/register-email-form.component.html @@ -24,6 +24,9 @@
{{MESSAGE_PREFIX + '.email.hint' |translate}}
+
+ +
@@ -32,5 +35,6 @@ + (click)="(captchaVersion === 'v2' && captchaMode === 'invisible') ? executeRecaptcha() : register()"> + {{MESSAGE_PREFIX + '.submit'| translate}} diff --git a/src/app/register-email-form/register-email-form.component.spec.ts b/src/app/register-email-form/register-email-form.component.spec.ts index 330933426a..55004c044b 100644 --- a/src/app/register-email-form/register-email-form.component.spec.ts +++ b/src/app/register-email-form/register-email-form.component.spec.ts @@ -1,5 +1,5 @@ import { waitForAsync, ComponentFixture, TestBed, tick, fakeAsync } from '@angular/core/testing'; -import { of as observableOf } from 'rxjs'; +import { of as observableOf, of } from 'rxjs'; import { RestResponse } from '../core/cache/response.models'; import { CommonModule } from '@angular/common'; import { RouterTestingModule } from '@angular/router/testing'; @@ -31,9 +31,13 @@ describe('RegisterEmailComponent', () => { }); const googleRecaptchaService = jasmine.createSpyObj('googleRecaptchaService', { - getRecaptchaToken: Promise.resolve('googleRecaptchaToken') + getRecaptchaToken: Promise.resolve('googleRecaptchaToken'), + executeRecaptcha: Promise.resolve('googleRecaptchaToken'), + getRecaptchaTokenResponse: Promise.resolve('googleRecaptchaToken') }); + const captchaVersion$ = of('v3'); + const captchaMode$ = of('invisible'); const confResponse$ = createSuccessfulRemoteDataObject$({ values: ['true'] }); const confResponseDisabled$ = createSuccessfulRemoteDataObject$({ values: ['false'] }); @@ -63,6 +67,8 @@ describe('RegisterEmailComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(RegisterEmailFormComponent); comp = fixture.componentInstance; + googleRecaptchaService.captchaVersion$ = captchaVersion$; + googleRecaptchaService.captchaMode$ = captchaMode$; configurationDataService.findByPropertyName.and.returnValues(confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$, confResponseDisabled$); fixture.detectChanges(); @@ -109,6 +115,8 @@ describe('RegisterEmailComponent', () => { describe('register with google recaptcha', () => { beforeEach(fakeAsync(() => { configurationDataService.findByPropertyName.and.returnValues(confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$, confResponse$); + googleRecaptchaService.captchaVersion$ = captchaVersion$; + googleRecaptchaService.captchaMode$ = captchaMode$; comp.ngOnInit(); fixture.detectChanges(); })); diff --git a/src/app/register-email-form/register-email-form.component.ts b/src/app/register-email-form/register-email-form.component.ts index 04081dc7ff..79303c8a04 100644 --- a/src/app/register-email-form/register-email-form.component.ts +++ b/src/app/register-email-form/register-email-form.component.ts @@ -7,11 +7,12 @@ import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms' import { Registration } from '../core/shared/registration.model'; import { RemoteData } from '../core/data/remote-data'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; -import { getFirstCompletedRemoteData } from '../core/shared/operators'; +import { getFirstCompletedRemoteData, getFirstSucceededRemoteDataPayload } from '../core/shared/operators'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; import { isNotEmpty } from '../shared/empty.util'; import { map } from 'rxjs/operators'; import { GoogleRecaptchaService } from '../core/google-recaptcha/google-recaptcha.service'; +import { Observable } from 'rxjs'; @Component({ selector: 'ds-register-email-form', @@ -38,6 +39,18 @@ export class RegisterEmailFormComponent implements OnInit { */ registrationVerification = false; + /** + * captcha version + */ + captchaVersion = 'v2'; + + /** + * captcha mode + */ + captchaMode = 'checkbox'; + + recaptchaKey$: Observable; + constructor( private epersonRegistrationService: EpersonRegistrationService, private notificationService: NotificationsService, @@ -45,7 +58,7 @@ export class RegisterEmailFormComponent implements OnInit { private router: Router, private formBuilder: FormBuilder, private configService: ConfigurationDataService, - private googleRecaptchaService: GoogleRecaptchaService + public googleRecaptchaService: GoogleRecaptchaService ) { } @@ -58,6 +71,15 @@ export class RegisterEmailFormComponent implements OnInit { ], }) }); + this.recaptchaKey$ = this.configService.findByPropertyName('google.recaptcha.key.site').pipe( + getFirstSucceededRemoteDataPayload(), + ); + this.googleRecaptchaService.captchaVersion$.subscribe(res => { + this.captchaVersion = res; + }); + this.googleRecaptchaService.captchaMode$.subscribe(res => { + this.captchaMode = res; + }); this.configService.findByPropertyName('registration.verification.enabled').pipe( getFirstCompletedRemoteData(), map((res: RemoteData) => { @@ -68,13 +90,27 @@ export class RegisterEmailFormComponent implements OnInit { }); } + /** + * execute the captcha function for v2 invisible + */ + async executeRecaptcha() { + await this.googleRecaptchaService.executeRecaptcha(); + } + /** * Register an email address */ - async register() { + async register(tokenV2 = null) { if (!this.form.invalid) { if (this.registrationVerification) { - const token = await this.googleRecaptchaService.getRecaptchaToken('register_email'); + let token; + if (this.captchaVersion === 'v3') { + token = await this.googleRecaptchaService.getRecaptchaToken('register_email'); + } else if (this.captchaMode === 'checkbox') { + token = await this.googleRecaptchaService.getRecaptchaTokenResponse(); + } else { + token = tokenV2; + } if (isNotEmpty(token)) { this.registration(token); } else { diff --git a/src/app/register-email-form/register-email-form.module.ts b/src/app/register-email-form/register-email-form.module.ts index 18c2830ff4..a765759413 100644 --- a/src/app/register-email-form/register-email-form.module.ts +++ b/src/app/register-email-form/register-email-form.module.ts @@ -2,13 +2,11 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { SharedModule } from '../shared/shared.module'; import { RegisterEmailFormComponent } from './register-email-form.component'; -import { GoogleRecaptchaModule } from '../core/google-recaptcha/google-recaptcha.module'; @NgModule({ imports: [ CommonModule, - SharedModule, - GoogleRecaptchaModule + SharedModule ], declarations: [ RegisterEmailFormComponent, diff --git a/src/app/shared/cookies/klaro-configuration.ts b/src/app/shared/cookies/klaro-configuration.ts index 659583ad87..1f780198f4 100644 --- a/src/app/shared/cookies/klaro-configuration.ts +++ b/src/app/shared/cookies/klaro-configuration.ts @@ -1,6 +1,7 @@ import { TOKENITEM } from '../../core/auth/models/auth-token-info.model'; import { IMPERSONATING_COOKIE, REDIRECT_COOKIE } from '../../core/auth/auth.service'; import { LANG_COOKIE } from '../../core/locale/locale.service'; +import { CAPTCHA_COOKIE } from 'src/app/core/google-recaptcha/google-recaptcha.service'; /** * Cookie for has_agreed_end_user @@ -155,5 +156,14 @@ export const klaroConfiguration: any = { */ onlyOnce: true, }, + { + name: 'google-recaptcha', + purposes: ['registration-password-recovery'], + required: true, + cookies: [ + CAPTCHA_COOKIE + ], + onlyOnce: true, + } ], }; diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.html b/src/app/shared/google-recaptcha/google-recaptcha.component.html new file mode 100644 index 0000000000..50bc51c808 --- /dev/null +++ b/src/app/shared/google-recaptcha/google-recaptcha.component.html @@ -0,0 +1,4 @@ +
\ No newline at end of file diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.scss b/src/app/shared/google-recaptcha/google-recaptcha.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.spec.ts b/src/app/shared/google-recaptcha/google-recaptcha.component.spec.ts new file mode 100644 index 0000000000..67f66c9757 --- /dev/null +++ b/src/app/shared/google-recaptcha/google-recaptcha.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NativeWindowService } from '../../core/services/window.service'; + +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { NativeWindowMockFactory } from '../mocks/mock-native-window-ref'; +import { createSuccessfulRemoteDataObject$ } from '../remote-data.utils'; +import { GoogleRecaptchaComponent } from './google-recaptcha.component'; + +describe('GoogleRecaptchaComponent', () => { + + let component: GoogleRecaptchaComponent; + + let fixture: ComponentFixture; + + + const configurationDataService = jasmine.createSpyObj('configurationDataService', { + findByPropertyName: jasmine.createSpy('findByPropertyName') + }); + + const confResponse$ = createSuccessfulRemoteDataObject$({ values: ['valid-google-recaptcha-key'] }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ GoogleRecaptchaComponent ], + providers: [ + { provide: ConfigurationDataService, useValue: configurationDataService }, + { provide: NativeWindowService, useFactory: NativeWindowMockFactory }, + ] + }) + .compileComponents(); + }); + + + beforeEach(() => { + fixture = TestBed.createComponent(GoogleRecaptchaComponent); + component = fixture.componentInstance; + configurationDataService.findByPropertyName.and.returnValues(confResponse$); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should rendered google recaptcha.',() => { + const container = fixture.debugElement.query(By.css('.g-recaptcha')); + expect(container).toBeTruthy(); + }); +}); diff --git a/src/app/shared/google-recaptcha/google-recaptcha.component.ts b/src/app/shared/google-recaptcha/google-recaptcha.component.ts new file mode 100644 index 0000000000..f9c9b599f6 --- /dev/null +++ b/src/app/shared/google-recaptcha/google-recaptcha.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Inject, Input, OnInit, Output } from '@angular/core'; + +import { ConfigurationDataService } from '../../core/data/configuration-data.service'; +import { getFirstSucceededRemoteDataPayload } from '../../core/shared/operators'; +import { Observable } from 'rxjs'; +import { NativeWindowRef, NativeWindowService } from 'src/app/core/services/window.service'; + +@Component({ + selector: 'ds-google-recaptcha', + templateUrl: './google-recaptcha.component.html', + styleUrls: ['./google-recaptcha.component.scss'], +}) +export class GoogleRecaptchaComponent implements OnInit { + + @Input() captchaMode: string; + /** + * An EventEmitter that's fired whenever the form is being submitted + */ + @Output() executeRecaptcha: EventEmitter = new EventEmitter(); + + recaptchaKey$: Observable; + + constructor( + @Inject(NativeWindowService) private _window: NativeWindowRef, + private configService: ConfigurationDataService, + ) { + } + + /** + * Retrieve the google recaptcha site key + */ + ngOnInit() { + this.recaptchaKey$ = this.configService.findByPropertyName('google.recaptcha.key.site').pipe( + getFirstSucceededRemoteDataPayload(), + ); + if (this.captchaMode === 'invisible') { + this._window.nativeWindow.executeRecaptcha = this.execute; + } + } + + execute = (event) => { + this.executeRecaptcha.emit(event); + }; + +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index f40ddd5b90..9ca2f22fa4 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -293,6 +293,7 @@ import { BrowserOnlyPipe } from './utils/browser-only.pipe'; import { ThemedLoadingComponent } from './loading/themed-loading.component'; import { PersonPageClaimButtonComponent } from './dso-page/person-page-claim-button/person-page-claim-button.component'; import { SearchExportCsvComponent } from './search/search-export-csv/search-export-csv.component'; +import { GoogleRecaptchaModule } from '../core/google-recaptcha/google-recaptcha.module'; const MODULES = [ CommonModule, @@ -313,7 +314,8 @@ const MODULES = [ NouisliderModule, MomentModule, DragDropModule, - CdkTreeModule + CdkTreeModule, + GoogleRecaptchaModule ]; const ROOT_MODULES = [ diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 7fd5fe1bcd..7aa884866f 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1262,10 +1262,27 @@ + "cookies.consent.app.title.google-recaptcha": "Google reCaptcha", + + "cookies.consent.app.description.google-recaptcha": "Allows us to track registration and password recovery data", + + + "cookies.consent.purpose.functional": "Functional", "cookies.consent.purpose.statistical": "Statistical", + "cookies.consent.purpose.registration-password-recovery": "Registration and Password recovery", + + "cookies.consent.purpose.sharing": "Sharing", + + "cris-layout.toggle.open": "Open section", + + "cris-layout.toggle.close": "Close section", + + "cris-layout.toggle.aria.open": "Expand {{sectionHeader}} section", + + "cris-layout.toggle.aria.close": "Collapse {{sectionHeader}} section", "curation-task.task.checklinks.label": "Check Links in Metadata",