Commit 4bfd4ead authored by Mark Harding's avatar Mark Harding

(feat): implements captcha - #646

1 merge request!760Implements captcha - #646
Pipeline #114882295 running with stages
......@@ -47,7 +47,6 @@ import { ScrollLock } from './directives/scroll-lock';
import { TagsLinks } from './directives/tags';
import { Tooltip } from './directives/tooltip';
import { MindsAvatar } from './components/avatar/avatar';
import { CaptchaComponent } from './components/captcha/captcha.component';
import { Textarea } from './components/editors/textarea.component';
import { TagcloudComponent } from './components/tagcloud/tagcloud.component';
import { DropdownComponent } from './components/dropdown/dropdown.component';
......@@ -209,7 +208,6 @@ const routes: Routes = [
MDL_DIRECTIVES,
DateSelectorComponent,
MindsAvatar,
CaptchaComponent,
Textarea,
InlineEditorComponent,
......@@ -316,7 +314,6 @@ const routes: Routes = [
MDL_DIRECTIVES,
DateSelectorComponent,
MindsAvatar,
CaptchaComponent,
Textarea,
InlineEditorComponent,
......
<div class="m-captcha--sum" *ngIf="type == 'sum'">
<div
class="m-captcha--sum-question "
*ngIf="question"
i18n="A sum (eg. 2 + 2)@@COMMON__CAPTCHA__SIMPLE_SUM"
>
What is {{ question[0] }} {{ question[1] }} {{ question[2] }}?
</div>
<input type="number" [(ngModel)]="answer" (keyup)="validate()" />
</div>
.m-captcha--sum {
text-align: left;
.m-captcha--sum-question {
font-size: 18px;
padding: 8px;
letter-spacing: 1px;
font-family: 'Roboto', Helvetica, sans-serif;
font-weight: 600;
display: inline-block;
}
input[type='number'] {
display: inline-block;
width: 46px;
font-size: 22px;
padding: 8px 0px;
text-align: center;
font-weight: 600;
font-family: 'Roboto', Helvetica, sans-serif;
box-sizing: content-box;
}
}
import { Component, Output, Input, EventEmitter } from '@angular/core';
import { Client } from '../../../services/api';
@Component({
selector: 'm-captcha',
templateUrl: 'captcha.component.html',
})
export class CaptchaComponent {
answer: string | number;
@Output('answer') emit: EventEmitter<any> = new EventEmitter();
inProgress: boolean = false;
type: string = 'sum';
question: Array<string | number>;
nonce: number;
hash: string = '';
interval;
constructor(public client: Client) {}
ngOnInit() {
this.get();
this.interval = setInterval(this.get, 1000 * 60 * 4); //refresh every 4 minutes
}
ngOnDestroy() {
clearInterval(this.interval);
}
get() {
this.client.get('api/v1/captcha').then((response: any) => {
this.type = response.question.type;
this.question = response.question.question;
this.nonce = response.question.nonce;
this.hash = response.question.hash;
});
}
validate() {
let payload = {
type: this.type,
question: this.question,
answer: this.answer,
nonce: this.nonce,
hash: this.hash,
};
this.emit.next(JSON.stringify(payload));
this.client.post('api/v1/captcha', payload).then((response: any) => {
if (response.success) console.log('success');
else console.log('error');
});
}
}
export class CaptchaService {}
<ng-container *ngIf="captcha">
<img [src]="captcha.base64Image" />
<input
[ngModel]="captcha.clientText"
(ngModelChange)="onValueChange($event)"
type="text"
placeholder="Enter the characters above"
/>
</ng-container>
m-captcha {
display: block;
img {
margin-bottom: $minds-margin;
}
}
import {
async,
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import { DebugElement } from '@angular/core';
import { CaptchaComponent, Captcha } from './captcha.component';
import { ReactiveFormsModule } from '@angular/forms';
import { Client } from '../../services/api';
import { clientMock } from '../../../tests/client-mock.spec';
import { By } from '@angular/platform-browser';
describe('CaptchaComponent', () => {
let comp: CaptchaComponent;
let fixture: ComponentFixture<CaptchaComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [CaptchaComponent],
imports: [ReactiveFormsModule],
providers: [{ provide: Client, useValue: clientMock }],
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CaptchaComponent);
comp = fixture.componentInstance;
fixture.detectChanges();
clientMock.response = {};
});
});
import {
Component,
ElementRef,
forwardRef,
OnChanges,
OnInit,
ViewChild,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { Client } from '../../services/api';
export const CAPTCHA_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CaptchaComponent),
multi: true,
};
export class Captcha {
jwtToken: string;
base64Image: string;
clientText: string; // This is what the user enters
buildClientKey(): string {
return JSON.stringify({
jwtToken: this.jwtToken,
clientText: this.clientText,
});
}
}
@Component({
selector: 'm-captcha',
templateUrl: 'captcha.component.html',
providers: [CAPTCHA_VALUE_ACCESSOR],
})
export class CaptchaComponent implements ControlValueAccessor, OnInit {
captcha = new Captcha();
image: string;
value: string = '';
propagateChange = (_: any) => {};
constructor(private client: Client) {}
ngOnInit(): void {
this.client
.get('api/v2/captcha', { cb: Date.now() })
.then((response: any) => {
this.captcha.base64Image = response.base64_image;
this.captcha.jwtToken = response.jwt_token;
});
}
onValueChange(value: string) {
this.captcha.clientText = value;
this.value = this.captcha.buildClientKey();
this.propagateChange(this.value);
}
writeValue(value: any): void {
// Not required as captcha is one direction
}
registerOnChange(fn: any): void {
this.propagateChange = fn;
}
registerOnTouched(fn: any): void {}
}
import { NgModule } from '@angular/core';
import { ReCaptchaComponent } from './recaptcha/recaptcha.component';
import { RECAPTCHA_SERVICE_PROVIDER } from './recaptcha/recaptcha.service';
import { CaptchaComponent } from './captcha.component';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [ReCaptchaComponent],
exports: [ReCaptchaComponent],
providers: [RECAPTCHA_SERVICE_PROVIDER],
imports: [CommonModule, FormsModule],
declarations: [CaptchaComponent],
exports: [CaptchaComponent],
})
export class CaptchaModule {}
import {
Component,
OnInit,
Input,
Output,
EventEmitter,
NgZone,
ViewChild,
ElementRef,
forwardRef,
} from '@angular/core';
import { NG_VALUE_ACCESSOR, ControlValueAccessor } from '@angular/forms';
import { ReCaptchaService } from './recaptcha.service';
@Component({
selector: 're-captcha',
template: '<div #target></div>',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ReCaptchaComponent),
multi: true,
},
],
})
export class ReCaptchaComponent implements OnInit, ControlValueAccessor {
@Input() site_key: string = null;
@Input() theme = 'light';
@Input() type = 'image';
@Input() size = 'normal';
@Input() tabindex = 0;
@Input() badge = 'bottomright';
/* Available languages: https://developers.google.com/recaptcha/docs/language */
@Input() language: string = null;
@Output() captchaResponse = new EventEmitter<string>();
@Output() captchaExpired = new EventEmitter();
@ViewChild('target', { static: true }) targetRef: ElementRef;
widgetId: any = null;
onChange: Function = () => {
return;
};
onTouched: Function = () => {
return;
};
constructor(
private _zone: NgZone,
private _captchaService: ReCaptchaService
) {}
ngOnInit() {
this._captchaService.getReady(this.language).subscribe(ready => {
if (!ready) return;
// noinspection TypeScriptUnresolvedVariable,TypeScriptUnresolvedFunction
this.widgetId = (<any>window).grecaptcha.render(
this.targetRef.nativeElement,
{
sitekey: this.site_key,
badge: this.badge,
theme: this.theme,
type: this.type,
size: this.size,
tabindex: this.tabindex,
callback: <any>(
((response: any) =>
this._zone.run(this.recaptchaCallback.bind(this, response)))
),
'expired-callback': <any>(
(() => this._zone.run(this.recaptchaExpiredCallback.bind(this)))
),
}
);
});
}
// noinspection JSUnusedGlobalSymbols
public reset() {
if (this.widgetId === null) return;
// noinspection TypeScriptUnresolvedVariable
(<any>window).grecaptcha.reset(this.widgetId);
this.onChange(null);
}
// noinspection JSUnusedGlobalSymbols
public execute() {
if (this.widgetId === null) return;
// noinspection TypeScriptUnresolvedVariable
(<any>window).grecaptcha.execute(this.widgetId);
}
public getResponse(): string {
if (this.widgetId === null) return null;
// noinspection TypeScriptUnresolvedVariable
return (<any>window).grecaptcha.getResponse(this.widgetId);
}
writeValue(newValue: any): void {
/* ignore it */
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
private recaptchaCallback(response: string) {
this.onChange(response);
this.onTouched();
this.captchaResponse.emit(response);
}
private recaptchaExpiredCallback() {
this.onChange(null);
this.onTouched();
this.captchaExpired.emit();
}
}
import { Injectable, NgZone, Optional, SkipSelf } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs';
/*
* Common service shared by all reCaptcha component instances
* through dependency injection.
* This service has the task of loading the reCaptcha API once for all.
* Only the first instance of the component creates the service, subsequent
* components will use the existing instance.
*
* As the language is passed to the <script>, the first component
* determines the language of all subsequent components. This is a limitation
* of the present Google API.
*/
@Injectable()
export class ReCaptchaService {
private scriptLoaded = false;
private readySubject: BehaviorSubject<boolean> = new BehaviorSubject(false);
constructor(zone: NgZone) {
/* the callback needs to exist before the API is loaded */
window[<any>'reCaptchaOnloadCallback'] = <any>(
(() => zone.run(this.onloadCallback.bind(this)))
);
}
public getReady(language: string): Observable<boolean> {
if (!this.scriptLoaded) {
this.scriptLoaded = true;
let doc = <HTMLDivElement>document.body;
let script = document.createElement('script');
script.innerHTML = '';
script.src =
'https://www.google.com/recaptcha/api.js?onload=reCaptchaOnloadCallback&render=explicit' +
(language ? '&hl=' + language : '');
script.async = true;
script.defer = true;
doc.appendChild(script);
}
return this.readySubject.asObservable();
}
private onloadCallback() {
this.readySubject.next(true);
}
}
/* singleton pattern taken from https://github.com/angular/angular/issues/13854 */
export function RECAPTCHA_SERVICE_PROVIDER_FACTORY(
ngZone: NgZone,
parentDispatcher: ReCaptchaService
) {
return parentDispatcher || new ReCaptchaService(ngZone);
}
export const RECAPTCHA_SERVICE_PROVIDER = {
provide: ReCaptchaService,
deps: [NgZone, [new Optional(), new SkipSelf(), ReCaptchaService]],
useFactory: RECAPTCHA_SERVICE_PROVIDER_FACTORY,
};
......@@ -134,6 +134,16 @@
</ng-container>
</div>
</div>
<div
*ngIf="form.value.password"
class="mdl-cell mdl-cell--12-col m-registerForm__captcha"
>
<label for="captcha" *ngIf="showLabels" i18n>
Captcha
</label>
<m-captcha formControlName="captcha"></m-captcha>
</div>
</div>
<div
......
......@@ -17,7 +17,6 @@ import {
import { Client } from '../../../services/api';
import { Session } from '../../../services/session';
import { ReCaptchaComponent } from '../../../modules/captcha/recaptcha/recaptcha.component';
import { ExperimentsService } from '../../experiments/experiments.service';
import { RouterHistoryService } from '../../../common/services/router-history.service';
import { PopoverComponent } from '../popover-validation/popover.component';
......@@ -28,7 +27,7 @@ import { FeaturesService } from '../../../services/features.service';
selector: 'minds-form-register',
templateUrl: 'register.html',
})
export class RegisterForm implements OnInit {
export class RegisterForm {
@Input() referrer: string;
@Input() parentId: string = '';
@Input() showTitle: boolean = false;
......@@ -53,7 +52,6 @@ export class RegisterForm implements OnInit {
form: FormGroup;
fbForm: FormGroup;
@ViewChild('reCaptcha', { static: false }) reCaptcha: ReCaptchaComponent;
@ViewChild('popover', { static: false }) popover: PopoverComponent;
constructor(
......@@ -86,12 +84,6 @@ export class RegisterForm implements OnInit {
);
}
ngOnInit() {
if (this.reCaptcha) {
this.reCaptcha.reset();
}
}
showError(field: string) {
return (
this.showInlineErrors &&
......@@ -121,10 +113,6 @@ export class RegisterForm implements OnInit {
'disabled_cookies=; expires=Thu, 01 Jan 1970 00:00:01 GMT;';
if (this.form.value.password !== this.form.value.password2) {
if (this.reCaptcha) {
this.reCaptcha.reset();
}
this.errorMessage = 'Passwords must match.';
return;
}
......@@ -148,9 +136,6 @@ export class RegisterForm implements OnInit {
.catch(e => {
console.log(e);
this.inProgress = false;
if (this.reCaptcha) {
this.reCaptcha.reset();
}
if (e.status === 'failed') {
// incorrect login details
......
Please register or to comment