feat(core): captcha service (#8616)

This commit is contained in:
EYHN
2024-10-28 18:06:52 +00:00
parent 81029db6ce
commit 7699296f11
12 changed files with 246 additions and 190 deletions
@@ -6,8 +6,10 @@ export {
isNetworkError,
NetworkError,
} from './error';
export { ValidatorProvider } from './provider/validator';
export { WebSocketAuthProvider } from './provider/websocket-auth';
export { AccountChanged, AuthService } from './services/auth';
export { CaptchaService } from './services/captcha';
export { FetchService } from './services/fetch';
export { GraphQLService } from './services/graphql';
export { InvoicesService } from './services/invoices';
@@ -38,8 +40,10 @@ import { UserCopilotQuota } from './entities/user-copilot-quota';
import { UserFeature } from './entities/user-feature';
import { UserQuota } from './entities/user-quota';
import { DefaultFetchProvider, FetchProvider } from './provider/fetch';
import { ValidatorProvider } from './provider/validator';
import { WebSocketAuthProvider } from './provider/websocket-auth';
import { AuthService } from './services/auth';
import { CaptchaService } from './services/captcha';
import { CloudDocMetaService } from './services/cloud-doc-meta';
import { FetchService } from './services/fetch';
import { GraphQLService } from './services/graphql';
@@ -75,6 +79,13 @@ export function configureCloudModule(framework: Framework) {
.service(ServerConfigService)
.entity(ServerConfig, [ServerConfigStore])
.store(ServerConfigStore, [GraphQLService])
.service(CaptchaService, f => {
return new CaptchaService(
f.get(ServerConfigService),
f.get(FetchService),
f.getOptional(ValidatorProvider)
);
})
.service(AuthService, [FetchService, AuthStore, UrlService])
.store(AuthStore, [FetchService, GraphQLService, GlobalState])
.entity(AuthSession, [AuthStore])
@@ -0,0 +1,12 @@
import { createIdentifier } from '@toeverything/infra';
export interface ValidatorProvider {
/**
* Calculate a token based on the server's challenge and resource to pass the
* challenge validation.
*/
validate: (challenge: string, resource: string) => Promise<string>;
}
export const ValidatorProvider =
createIdentifier<ValidatorProvider>('ValidatorProvider');
@@ -113,7 +113,7 @@ export class AuthService extends Service {
async sendEmailMagicLink(
email: string,
verifyToken: string,
verifyToken?: string,
challenge?: string,
redirectUrl?: string // url to redirect to after signed-in
) {
@@ -137,7 +137,7 @@ export class AuthService extends Service {
}),
headers: {
'content-type': 'application/json',
...this.captchaHeaders(verifyToken, challenge),
...(verifyToken ? this.captchaHeaders(verifyToken, challenge) : {}),
},
});
} catch (e) {
@@ -224,7 +224,7 @@ export class AuthService extends Service {
async signInPassword(credential: {
email: string;
password: string;
verifyToken: string;
verifyToken?: string;
challenge?: string;
}) {
track.$.$.auth.signIn({ method: 'password' });
@@ -234,7 +234,9 @@ export class AuthService extends Service {
body: JSON.stringify(credential),
headers: {
'content-type': 'application/json',
...this.captchaHeaders(credential.verifyToken, credential.challenge),
...(credential.verifyToken
? this.captchaHeaders(credential.verifyToken, credential.challenge)
: {}),
},
});
this.session.revalidate();
@@ -0,0 +1,75 @@
import {
catchErrorInto,
effect,
fromPromise,
LiveData,
onComplete,
onStart,
Service,
} from '@toeverything/infra';
import { EMPTY, mergeMap, switchMap } from 'rxjs';
import type { ValidatorProvider } from '../provider/validator';
import type { FetchService } from './fetch';
import type { ServerConfigService } from './server-config';
export class CaptchaService extends Service {
needCaptcha$ = this.serverConfigService.serverConfig.features$.map(
r => r?.captcha || false
);
challenge$ = new LiveData<string | undefined>(undefined);
isLoading$ = new LiveData(false);
verifyToken$ = new LiveData<string | undefined>(undefined);
error$ = new LiveData<any | undefined>(undefined);
constructor(
private readonly serverConfigService: ServerConfigService,
private readonly fetchService: FetchService,
public readonly validatorProvider?: ValidatorProvider
) {
super();
}
revalidate = effect(
switchMap(() => {
return fromPromise(async signal => {
if (!this.needCaptcha$.value) {
return {};
}
const res = await this.fetchService.fetch('/api/auth/challenge', {
signal,
});
const data = (await res.json()) as {
challenge: string;
resource: string;
};
if (!data || !data.challenge || !data.resource) {
throw new Error('Invalid challenge');
}
if (this.validatorProvider) {
const token = await this.validatorProvider.validate(
data.challenge,
data.resource
);
return {
token,
challenge: data.challenge,
};
}
return { challenge: data.challenge, token: undefined };
}).pipe(
mergeMap(({ challenge, token }) => {
this.verifyToken$.next(token);
this.challenge$.next(challenge);
return EMPTY;
}),
catchErrorInto(this.error$),
onStart(() => {
this.verifyToken$.next(undefined);
this.isLoading$.next(true);
}),
onComplete(() => this.isLoading$.next(false))
);
})
);
}