mirror of
https://github.com/bitwarden/browser.git
synced 2024-11-27 12:36:14 +01:00
Feature/use hcaptcha if bot (#430)
* Handle hcaptch required identity response * Refactor iframe component for captcha and webauthn * Send captcha token to server * Add captcha callback * Clear captcha state * Remove captcha storage * linter fixes * Rename iframe components to include IFrame * Remove callback in favor of extenting submit * Limit publickey credentials access * Use captcha bypass token to bypass captcha for twofactor auth flows * Linter fixes * Set iframe version in components
This commit is contained in:
parent
00acbce556
commit
1006f50ef3
@ -19,6 +19,7 @@ import { StorageService } from 'jslib-common/abstractions/storage.service';
|
||||
|
||||
import { ConstantsService } from 'jslib-common/services/constants.service';
|
||||
|
||||
import { CaptchaIFrame } from 'jslib-common/misc/captcha_iframe';
|
||||
import { Utils } from 'jslib-common/misc/utils';
|
||||
|
||||
const Keys = {
|
||||
@ -33,6 +34,9 @@ export class LoginComponent implements OnInit {
|
||||
|
||||
masterPassword: string = '';
|
||||
showPassword: boolean = false;
|
||||
captchaSiteKey: string = null;
|
||||
captchaToken: string = null;
|
||||
captcha: CaptchaIFrame;
|
||||
formPromise: Promise<AuthResult>;
|
||||
onSuccessfulLogin: () => Promise<any>;
|
||||
onSuccessfulLoginNavigate: () => Promise<any>;
|
||||
@ -61,6 +65,20 @@ export class LoginComponent implements OnInit {
|
||||
if (Utils.isBrowser && !Utils.isNode) {
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
let webVaultUrl = this.environmentService.getWebVaultUrl();
|
||||
if (webVaultUrl == null) {
|
||||
webVaultUrl = 'https://vault.bitwarden.com';
|
||||
}
|
||||
this.captcha = new CaptchaIFrame(window, webVaultUrl,
|
||||
this.i18nService, (token: string) => {
|
||||
this.captchaToken = token;
|
||||
}, (error: string) => {
|
||||
this.platformUtilsService.showToast('error', this.i18nService.t('errorOccurred'), error);
|
||||
}, (info: string) => {
|
||||
this.platformUtilsService.showToast('info', this.i18nService.t('info'), info);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
@ -81,7 +99,7 @@ export class LoginComponent implements OnInit {
|
||||
}
|
||||
|
||||
try {
|
||||
this.formPromise = this.authService.logIn(this.email, this.masterPassword);
|
||||
this.formPromise = this.authService.logIn(this.email, this.masterPassword, this.captchaToken);
|
||||
const response = await this.formPromise;
|
||||
await this.storageService.save(Keys.rememberEmail, this.rememberEmail);
|
||||
if (this.rememberEmail) {
|
||||
@ -89,7 +107,10 @@ export class LoginComponent implements OnInit {
|
||||
} else {
|
||||
await this.storageService.remove(Keys.rememberedEmail);
|
||||
}
|
||||
if (response.twoFactor) {
|
||||
if (!Utils.isNullOrWhitespace(response.captchaSiteKey)) {
|
||||
this.captchaSiteKey = response.captchaSiteKey;
|
||||
this.captcha.init(response.captchaSiteKey);
|
||||
} else if (response.twoFactor) {
|
||||
if (this.onSuccessfulLoginTwoFactorNavigate != null) {
|
||||
this.onSuccessfulLoginTwoFactorNavigate();
|
||||
} else {
|
||||
@ -144,6 +165,9 @@ export class LoginComponent implements OnInit {
|
||||
'&state=' + state + '&codeChallenge=' + codeChallenge);
|
||||
}
|
||||
|
||||
showCaptcha() {
|
||||
return !Utils.isNullOrWhitespace(this.captchaSiteKey);
|
||||
}
|
||||
protected focusInput() {
|
||||
document.getElementById(this.email == null || this.email === '' ? 'email' : 'masterPassword').focus();
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import { TwoFactorProviders } from 'jslib-common/services/auth.service';
|
||||
import { ConstantsService } from 'jslib-common/services/constants.service';
|
||||
|
||||
import * as DuoWebSDK from 'duo_web_sdk';
|
||||
import { WebAuthn } from 'jslib-common/misc/webauthn';
|
||||
import { WebAuthnIFrame } from 'jslib-common/misc/webauthn_iframe';
|
||||
|
||||
@Directive()
|
||||
export class TwoFactorComponent implements OnInit, OnDestroy {
|
||||
@ -35,7 +35,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy {
|
||||
providerType = TwoFactorProviderType;
|
||||
selectedProviderType: TwoFactorProviderType = TwoFactorProviderType.Authenticator;
|
||||
webAuthnSupported: boolean = false;
|
||||
webAuthn: WebAuthn = null;
|
||||
webAuthn: WebAuthnIFrame = null;
|
||||
title: string = '';
|
||||
twoFactorEmail: string = null;
|
||||
formPromise: Promise<any>;
|
||||
@ -80,7 +80,7 @@ export class TwoFactorComponent implements OnInit, OnDestroy {
|
||||
if (webVaultUrl == null) {
|
||||
webVaultUrl = 'https://vault.bitwarden.com';
|
||||
}
|
||||
this.webAuthn = new WebAuthn(this.win, webVaultUrl, this.webAuthnNewTab, this.platformUtilsService,
|
||||
this.webAuthn = new WebAuthnIFrame(this.win, webVaultUrl, this.webAuthnNewTab, this.platformUtilsService,
|
||||
this.i18nService, (token: string) => {
|
||||
this.token = token;
|
||||
this.submit();
|
||||
|
@ -108,6 +108,7 @@ import {
|
||||
GroupDetailsResponse,
|
||||
GroupResponse,
|
||||
} from '../models/response/groupResponse';
|
||||
import { IdentityCaptchaResponse } from '../models/response/identityCaptchaResponse';
|
||||
import { IdentityTokenResponse } from '../models/response/identityTokenResponse';
|
||||
import { IdentityTwoFactorResponse } from '../models/response/identityTwoFactorResponse';
|
||||
import { ListResponse } from '../models/response/listResponse';
|
||||
@ -158,7 +159,7 @@ export abstract class ApiService {
|
||||
eventsBaseUrl: string;
|
||||
|
||||
setUrls: (urls: EnvironmentUrls) => void;
|
||||
postIdentityToken: (request: TokenRequest) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse>;
|
||||
postIdentityToken: (request: TokenRequest) => Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse>;
|
||||
refreshIdentityToken: () => Promise<any>;
|
||||
|
||||
getProfile: () => Promise<ProfileResponse>;
|
||||
|
@ -14,7 +14,7 @@ export abstract class AuthService {
|
||||
twoFactorProvidersData: Map<TwoFactorProviderType, { [key: string]: string; }>;
|
||||
selectedTwoFactorProviderType: TwoFactorProviderType;
|
||||
|
||||
logIn: (email: string, masterPassword: string) => Promise<AuthResult>;
|
||||
logIn: (email: string, masterPassword: string, captchaToken?: string) => Promise<AuthResult>;
|
||||
logInSso: (code: string, codeVerifier: string, redirectUrl: string) => Promise<AuthResult>;
|
||||
logInApiKey: (clientId: string, clientSecret: string) => Promise<AuthResult>;
|
||||
logInTwoFactor: (twoFactorProvider: TwoFactorProviderType, twoFactorToken: string,
|
||||
|
22
common/src/misc/captcha_iframe.ts
Normal file
22
common/src/misc/captcha_iframe.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { I18nService } from '../abstractions/i18n.service';
|
||||
import { IFrameComponent } from './iframe_component';
|
||||
|
||||
export class CaptchaIFrame extends IFrameComponent {
|
||||
constructor(win: Window, webVaultUrl: string,
|
||||
private i18nService: I18nService, successCallback: (message: string) => any, errorCallback: (message: string) => any,
|
||||
infoCallback: (message: string) => any) {
|
||||
super(win, webVaultUrl, 'captcha-connector.html', 'hcaptcha_iframe', successCallback, errorCallback, (message: string) => {
|
||||
const parsedMessage = JSON.parse(message);
|
||||
if (typeof (parsedMessage) !== 'string') {
|
||||
this.iframe.height = (parsedMessage.height).toString();
|
||||
this.iframe.width = (parsedMessage.width).toString();
|
||||
} else {
|
||||
infoCallback(parsedMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init(siteKey: string): void {
|
||||
super.initComponent(this.createParams({ siteKey: siteKey, locale: this.i18nService.translationLocale }, 1));
|
||||
}
|
||||
}
|
@ -1,39 +1,16 @@
|
||||
import { I18nService } from '../abstractions/i18n.service';
|
||||
import { PlatformUtilsService } from '../abstractions/platformUtils.service';
|
||||
|
||||
export class WebAuthn {
|
||||
private iframe: HTMLIFrameElement = null;
|
||||
export abstract class IFrameComponent {
|
||||
iframe: HTMLIFrameElement;
|
||||
private connectorLink: HTMLAnchorElement;
|
||||
private parseFunction = this.parseMessage.bind(this);
|
||||
|
||||
constructor(private win: Window, private webVaultUrl: string, private webAuthnNewTab: boolean,
|
||||
private platformUtilsService: PlatformUtilsService, private i18nService: I18nService,
|
||||
private successCallback: Function, private errorCallback: Function, private infoCallback: Function) {
|
||||
constructor(private win: Window, protected webVaultUrl: string, private path: string, private iframeId: string,
|
||||
public successCallback?: (message: string) => any,
|
||||
public errorCallback?: (message: string) => any, public infoCallback?: (message: string) => any) {
|
||||
this.connectorLink = win.document.createElement('a');
|
||||
}
|
||||
|
||||
init(data: any): void {
|
||||
const params = new URLSearchParams({
|
||||
data: this.base64Encode(JSON.stringify(data)),
|
||||
parent: encodeURIComponent(this.win.document.location.href),
|
||||
btnText: encodeURIComponent(this.i18nService.t('webAuthnAuthenticate')),
|
||||
v: '1',
|
||||
});
|
||||
|
||||
if (this.webAuthnNewTab) {
|
||||
// Firefox fallback which opens the webauthn page in a new tab
|
||||
params.append('locale', this.i18nService.translationLocale);
|
||||
this.platformUtilsService.launchUri(`${this.webVaultUrl}/webauthn-fallback-connector.html?${params}`);
|
||||
} else {
|
||||
this.connectorLink.href = `${this.webVaultUrl}/webauthn-connector.html?${params}`;
|
||||
this.iframe = this.win.document.getElementById('webauthn_iframe') as HTMLIFrameElement;
|
||||
this.iframe.allow = 'publickey-credentials-get ' + new URL(this.webVaultUrl).origin;
|
||||
this.iframe.src = this.connectorLink.href;
|
||||
|
||||
this.win.addEventListener('message', this.parseFunction, false);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.sendMessage('stop');
|
||||
}
|
||||
@ -60,6 +37,22 @@ export class WebAuthn {
|
||||
this.win.removeEventListener('message', this.parseFunction, false);
|
||||
}
|
||||
|
||||
protected createParams(data: any, version: number) {
|
||||
return new URLSearchParams({
|
||||
data: this.base64Encode(JSON.stringify(data)),
|
||||
parent: encodeURIComponent(this.win.document.location.href),
|
||||
v: version.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
protected initComponent(params: URLSearchParams): void {
|
||||
this.connectorLink.href = `${this.webVaultUrl}/${this.path}?${params}`;
|
||||
this.iframe = this.win.document.getElementById(this.iframeId) as HTMLIFrameElement;
|
||||
this.iframe.src = this.connectorLink.href;
|
||||
|
||||
this.win.addEventListener('message', this.parseFunction, false);
|
||||
}
|
||||
|
||||
private parseMessage(event: MessageEvent) {
|
||||
if (!this.validMessage(event)) {
|
||||
return;
|
26
common/src/misc/webauthn_iframe.ts
Normal file
26
common/src/misc/webauthn_iframe.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { I18nService } from '../abstractions/i18n.service';
|
||||
import { PlatformUtilsService } from '../abstractions/platformUtils.service';
|
||||
import { IFrameComponent } from './iframe_component';
|
||||
|
||||
export class WebAuthnIFrame extends IFrameComponent {
|
||||
constructor(win: Window, webVaultUrl: string, private webAuthnNewTab: boolean,
|
||||
private platformUtilsService: PlatformUtilsService, private i18nService: I18nService,
|
||||
successCallback: (message: string) => any, errorCallback: (message: string) => any,
|
||||
infoCallback: (message: string) => any) {
|
||||
super(win, webVaultUrl, 'webauthn-connector.html', 'webauthn_iframe', successCallback, errorCallback, infoCallback);
|
||||
}
|
||||
|
||||
|
||||
init(data: any): void {
|
||||
const params = this.createParams({ data: JSON.stringify(data), btnText: this.i18nService.t('webAuthnAuthenticate') }, 2);
|
||||
|
||||
if (this.webAuthnNewTab) {
|
||||
// Firefox fallback which opens the webauthn page in a new tab
|
||||
params.append('locale', this.i18nService.translationLocale);
|
||||
this.platformUtilsService.launchUri(`${this.webVaultUrl}/webauthn-fallback-connector.html?${params}`);
|
||||
} else {
|
||||
super.initComponent(params);
|
||||
this.iframe.allow = 'publickey-credentials-get ' + new URL(this.webVaultUrl).origin;
|
||||
}
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import { TwoFactorProviderType } from '../../enums/twoFactorProviderType';
|
||||
|
||||
export class AuthResult {
|
||||
twoFactor: boolean = false;
|
||||
captchaSiteKey: string = '';
|
||||
resetMasterPassword: boolean = false;
|
||||
twoFactorProviders: Map<TwoFactorProviderType, { [key: string]: string; }> = null;
|
||||
}
|
||||
|
@ -13,10 +13,11 @@ export class TokenRequest {
|
||||
token: string;
|
||||
provider: TwoFactorProviderType;
|
||||
remember: boolean;
|
||||
captchaToken: string;
|
||||
device?: DeviceRequest;
|
||||
|
||||
constructor(credentials: string[], codes: string[], clientIdClientSecret: string[], provider: TwoFactorProviderType,
|
||||
token: string, remember: boolean, device?: DeviceRequest) {
|
||||
token: string, remember: boolean, captchaToken: string, device?: DeviceRequest) {
|
||||
if (credentials != null && credentials.length > 1) {
|
||||
this.email = credentials[0];
|
||||
this.masterPasswordHash = credentials[1];
|
||||
@ -32,6 +33,7 @@ export class TokenRequest {
|
||||
this.provider = provider;
|
||||
this.remember = remember;
|
||||
this.device = device != null ? device : null;
|
||||
this.captchaToken = captchaToken;
|
||||
}
|
||||
|
||||
toIdentityToken(clientId: string) {
|
||||
@ -71,6 +73,11 @@ export class TokenRequest {
|
||||
obj.twoFactorRemember = this.remember ? '1' : '0';
|
||||
}
|
||||
|
||||
if (this.captchaToken != null) {
|
||||
obj.captchaResponse = this.captchaToken;
|
||||
}
|
||||
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
|
10
common/src/models/response/identityCaptchaResponse.ts
Normal file
10
common/src/models/response/identityCaptchaResponse.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { BaseResponse } from './baseResponse';
|
||||
|
||||
export class IdentityCaptchaResponse extends BaseResponse {
|
||||
siteKey: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.siteKey = this.getResponseProperty('HCaptcha_SiteKey');
|
||||
}
|
||||
}
|
@ -5,9 +5,11 @@ import { TwoFactorProviderType } from '../../enums/twoFactorProviderType';
|
||||
export class IdentityTwoFactorResponse extends BaseResponse {
|
||||
twoFactorProviders: TwoFactorProviderType[];
|
||||
twoFactorProviders2 = new Map<TwoFactorProviderType, { [key: string]: string; }>();
|
||||
captchaToken: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.captchaToken = this.getResponseProperty('HCaptcha_BypassKey');
|
||||
this.twoFactorProviders = this.getResponseProperty('TwoFactorProviders');
|
||||
const twoFactorProviders2 = this.getResponseProperty('TwoFactorProviders2');
|
||||
if (twoFactorProviders2 != null) {
|
||||
|
@ -160,6 +160,7 @@ import { ChallengeResponse } from '../models/response/twoFactorWebAuthnResponse'
|
||||
import { TwoFactorYubiKeyResponse } from '../models/response/twoFactorYubiKeyResponse';
|
||||
import { UserKeyResponse } from '../models/response/userKeyResponse';
|
||||
|
||||
import { IdentityCaptchaResponse } from '../models/response/identityCaptchaResponse';
|
||||
import { SendAccessView } from '../models/view/sendAccessView';
|
||||
|
||||
export class ApiService implements ApiServiceAbstraction {
|
||||
@ -215,7 +216,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
// Auth APIs
|
||||
|
||||
async postIdentityToken(request: TokenRequest): Promise<IdentityTokenResponse | IdentityTwoFactorResponse> {
|
||||
async postIdentityToken(request: TokenRequest): Promise<IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse> {
|
||||
const headers = new Headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
'Accept': 'application/json',
|
||||
@ -245,6 +246,9 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
Object.keys(responseJson.TwoFactorProviders2).length) {
|
||||
await this.tokenService.clearTwoFactorToken(request.email);
|
||||
return new IdentityTwoFactorResponse(responseJson);
|
||||
} else if (response.status === 400 && responseJson.HCaptcha_SiteKey &&
|
||||
Object.keys(responseJson.HCaptcha_SiteKey).length) {
|
||||
return new IdentityCaptchaResponse(responseJson);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,6 +87,7 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
clientSecret: string;
|
||||
twoFactorProvidersData: Map<TwoFactorProviderType, { [key: string]: string; }>;
|
||||
selectedTwoFactorProviderType: TwoFactorProviderType = null;
|
||||
captchaToken: string;
|
||||
|
||||
private key: SymmetricCryptoKey;
|
||||
|
||||
@ -120,14 +121,14 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
TwoFactorProviders[TwoFactorProviderType.Yubikey].description = this.i18nService.t('yubiKeyDesc');
|
||||
}
|
||||
|
||||
async logIn(email: string, masterPassword: string): Promise<AuthResult> {
|
||||
async logIn(email: string, masterPassword: string, captchaToken?: string): Promise<AuthResult> {
|
||||
this.selectedTwoFactorProviderType = null;
|
||||
const key = await this.makePreloginKey(masterPassword, email);
|
||||
const hashedPassword = await this.cryptoService.hashPassword(masterPassword, key);
|
||||
const localHashedPassword = await this.cryptoService.hashPassword(masterPassword, key,
|
||||
HashPurpose.LocalAuthorization);
|
||||
return await this.logInHelper(email, hashedPassword, localHashedPassword, null, null, null, null, null,
|
||||
key, null, null, null);
|
||||
key, null, null, null, captchaToken);
|
||||
}
|
||||
|
||||
async logInSso(code: string, codeVerifier: string, redirectUrl: string): Promise<AuthResult> {
|
||||
@ -146,7 +147,7 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
remember?: boolean): Promise<AuthResult> {
|
||||
return await this.logInHelper(this.email, this.masterPasswordHash, this.localMasterPasswordHash, this.code,
|
||||
this.codeVerifier, this.ssoRedirectUrl, this.clientId, this.clientSecret, this.key, twoFactorProvider,
|
||||
twoFactorToken, remember);
|
||||
twoFactorToken, remember, this.captchaToken);
|
||||
}
|
||||
|
||||
async logInComplete(email: string, masterPassword: string, twoFactorProvider: TwoFactorProviderType,
|
||||
@ -272,7 +273,7 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
|
||||
private async logInHelper(email: string, hashedPassword: string, localHashedPassword: string, code: string,
|
||||
codeVerifier: string, redirectUrl: string, clientId: string, clientSecret: string, key: SymmetricCryptoKey,
|
||||
twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean): Promise<AuthResult> {
|
||||
twoFactorProvider?: TwoFactorProviderType, twoFactorToken?: string, remember?: boolean, captchaToken?: string): Promise<AuthResult> {
|
||||
const storedTwoFactorToken = await this.tokenService.getTwoFactorToken(email);
|
||||
const appId = await this.appIdService.getAppId();
|
||||
const deviceRequest = new DeviceRequest(appId, this.platformUtilsService);
|
||||
@ -300,24 +301,27 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
let request: TokenRequest;
|
||||
if (twoFactorToken != null && twoFactorProvider != null) {
|
||||
request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, twoFactorProvider,
|
||||
twoFactorToken, remember, deviceRequest);
|
||||
twoFactorToken, remember, captchaToken, deviceRequest);
|
||||
} else if (storedTwoFactorToken != null) {
|
||||
request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, TwoFactorProviderType.Remember,
|
||||
storedTwoFactorToken, false, deviceRequest);
|
||||
request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret,
|
||||
TwoFactorProviderType.Remember, storedTwoFactorToken, false, captchaToken, deviceRequest);
|
||||
} else {
|
||||
request = new TokenRequest(emailPassword, codeCodeVerifier, clientIdClientSecret, null,
|
||||
null, false, deviceRequest);
|
||||
null, false, captchaToken, deviceRequest);
|
||||
}
|
||||
|
||||
const response = await this.apiService.postIdentityToken(request);
|
||||
|
||||
this.clearState();
|
||||
const result = new AuthResult();
|
||||
result.twoFactor = !(response as any).accessToken;
|
||||
result.captchaSiteKey = (response as any).siteKey;
|
||||
if (!!result.captchaSiteKey) {
|
||||
return result;
|
||||
}
|
||||
result.twoFactor = !!(response as any).twoFactorProviders2;
|
||||
|
||||
if (result.twoFactor) {
|
||||
// two factor required
|
||||
const twoFactorResponse = response as IdentityTwoFactorResponse;
|
||||
this.email = email;
|
||||
this.masterPasswordHash = hashedPassword;
|
||||
this.localMasterPasswordHash = localHashedPassword;
|
||||
@ -327,8 +331,10 @@ export class AuthService implements AuthServiceAbstraction {
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.key = this.setCryptoKeys ? key : null;
|
||||
const twoFactorResponse = response as IdentityTwoFactorResponse;
|
||||
this.twoFactorProvidersData = twoFactorResponse.twoFactorProviders2;
|
||||
result.twoFactorProviders = twoFactorResponse.twoFactorProviders2;
|
||||
this.captchaToken = twoFactorResponse.captchaToken;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user