mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-24 13:58:50 +08:00
fix(core): selfhosted auth handling (#15295)
fix #15284 fix #15266 fix #15268 fix #15267 #### PR Dependency Tree * **PR #15295** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { notify, toast } from '@affine/component';
|
||||
import {
|
||||
pushGlobalLoadingEventAtom,
|
||||
resolveGlobalLoadingEventAtom,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AuthService,
|
||||
EventSourceService,
|
||||
GraphQLService,
|
||||
RealtimeService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import {
|
||||
GlobalDialogService,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
fromPromise,
|
||||
onStart,
|
||||
throwIfAborted,
|
||||
useLiveData,
|
||||
useService,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
@@ -142,6 +144,20 @@ export const WorkspaceSideEffects = () => {
|
||||
const eventSourceService = useService(EventSourceService);
|
||||
const authService = useService(AuthService);
|
||||
const nbstoreService = useService(NbstoreService);
|
||||
const realtimeConnectionError = useLiveData(
|
||||
useService(RealtimeService).connectionError$
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!realtimeConnectionError) return;
|
||||
notify.warning(
|
||||
{
|
||||
title: t['com.affine.realtime.connection-error.title'](),
|
||||
message: t['com.affine.realtime.connection-error.message'](),
|
||||
},
|
||||
{ id: `realtime-connection-error:${realtimeConnectionError.endpoint}` }
|
||||
);
|
||||
}, [realtimeConnectionError, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const dispose = setupAIProvider(
|
||||
|
||||
@@ -93,6 +93,14 @@ export class AuthSession extends Entity {
|
||||
)
|
||||
);
|
||||
|
||||
async revalidateOnce() {
|
||||
const sessionInfo = await this.getSession();
|
||||
if (!isEqual(this.store.getCachedAuthSession(), sessionInfo)) {
|
||||
this.store.setCachedAuthSession(sessionInfo);
|
||||
}
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
private async getSession(): Promise<AuthSessionInfo | null> {
|
||||
try {
|
||||
const session = await this.store.fetchSession();
|
||||
|
||||
@@ -166,7 +166,7 @@ export class AuthService extends Service {
|
||||
try {
|
||||
await this.store.signInMagicLink(email, token);
|
||||
|
||||
this.session.revalidate();
|
||||
await this.session.revalidateOnce();
|
||||
track.$.$.auth.signedIn({ method });
|
||||
} catch (e) {
|
||||
track.$.$.auth.signInFail({
|
||||
@@ -218,7 +218,7 @@ export class AuthService extends Service {
|
||||
provider
|
||||
);
|
||||
|
||||
this.session.revalidate();
|
||||
await this.session.revalidateOnce();
|
||||
|
||||
track.$.$.auth.signedIn({ method: 'oauth', provider });
|
||||
return { redirectUri };
|
||||
@@ -251,7 +251,7 @@ export class AuthService extends Service {
|
||||
async signInOpenAppSignInCode(code: string) {
|
||||
await this.store.signInOpenAppSignInCode(code);
|
||||
|
||||
this.session.revalidate();
|
||||
await this.session.revalidateOnce();
|
||||
}
|
||||
|
||||
async signInPassword(credential: {
|
||||
@@ -266,8 +266,10 @@ export class AuthService extends Service {
|
||||
const user = await this.store.signInPassword(credential);
|
||||
if (user) {
|
||||
this.store.setCachedSignInUser(user);
|
||||
this.session.revalidate();
|
||||
} else {
|
||||
await this.session.revalidateOnce();
|
||||
}
|
||||
this.session.revalidate();
|
||||
track.$.$.auth.signedIn({ method: 'password' });
|
||||
} catch (e) {
|
||||
track.$.$.auth.signInFail({
|
||||
|
||||
@@ -10,6 +10,12 @@ import type { ServersService } from './servers';
|
||||
|
||||
@OnEvent(ApplicationStarted, service => service.onApplicationStarted)
|
||||
export class RealtimeService extends Service {
|
||||
readonly connectionError$ = new LiveData<{
|
||||
endpoint: string;
|
||||
error: unknown;
|
||||
} | null>(null);
|
||||
private contextGeneration = 0;
|
||||
|
||||
private readonly currentServer$ =
|
||||
this.globalContextService.globalContext.serverId.$.selector(id =>
|
||||
id
|
||||
@@ -43,7 +49,8 @@ export class RealtimeService extends Service {
|
||||
super();
|
||||
|
||||
const subscription = this.currentServer$.subscribe(context => {
|
||||
this.nbstoreService.realtime.configure(context).catch(error => {
|
||||
const generation = ++this.contextGeneration;
|
||||
this.configure(context, generation).catch(error => {
|
||||
console.error('Failed to configure realtime context', error);
|
||||
});
|
||||
});
|
||||
@@ -51,4 +58,35 @@ export class RealtimeService extends Service {
|
||||
}
|
||||
|
||||
onApplicationStarted() {}
|
||||
|
||||
private async configure(
|
||||
context: {
|
||||
endpoint: string;
|
||||
authenticated: boolean;
|
||||
isSelfHosted: boolean;
|
||||
},
|
||||
generation: number
|
||||
) {
|
||||
await this.nbstoreService.realtime.configure(context);
|
||||
if (generation !== this.contextGeneration) return;
|
||||
if (!context.endpoint || !context.authenticated) {
|
||||
this.connectionError$.next(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.nbstoreService.realtime.request(
|
||||
'user.profile.get',
|
||||
{},
|
||||
{ timeoutMs: 10_000 }
|
||||
);
|
||||
if (generation === this.contextGeneration) {
|
||||
this.connectionError$.next(null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === this.contextGeneration) {
|
||||
this.connectionError$.next({ endpoint: context.endpoint, error });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
function createStore({
|
||||
fetch,
|
||||
request,
|
||||
gql = vi.fn(),
|
||||
}: {
|
||||
fetch: (input: string, init?: RequestInit) => Promise<Response>;
|
||||
request: (op: string, input: object) => Promise<unknown>;
|
||||
gql?: () => Promise<unknown>;
|
||||
}) {
|
||||
const framework = new Framework();
|
||||
framework.service(FetchService, { fetch } as any);
|
||||
framework.service(GraphQLService, {} as any);
|
||||
framework.service(GraphQLService, { gql } as any);
|
||||
framework.impl(GlobalState, {} as any);
|
||||
framework.service(ServerService, {
|
||||
server: {
|
||||
@@ -26,7 +26,7 @@ function createStore({
|
||||
} as any);
|
||||
framework.impl(AuthProvider, {} as any);
|
||||
framework.service(NbstoreService, {
|
||||
realtime: { request },
|
||||
realtime: {},
|
||||
} as any);
|
||||
framework.store(AuthStore, [
|
||||
FetchService,
|
||||
@@ -57,7 +57,7 @@ describe('AuthStore', () => {
|
||||
ok: true,
|
||||
json: async () => ({ registered: true, methods: {} }),
|
||||
});
|
||||
const store = createStore({ fetch, request: vi.fn() });
|
||||
const store = createStore({ fetch });
|
||||
|
||||
await expect(store.checkUserByEmail('user@affine.pro')).resolves.toEqual(
|
||||
validResponse
|
||||
@@ -70,7 +70,7 @@ describe('AuthStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('loads account profile from realtime after auth session bootstrap', async () => {
|
||||
test('loads account profile from scoped GraphQL after auth session bootstrap', async () => {
|
||||
const authMethods = {
|
||||
password: { bound: true },
|
||||
oauth: { bound: false, providers: [] },
|
||||
@@ -90,8 +90,8 @@ describe('AuthStore', () => {
|
||||
}
|
||||
throw new Error(`Unexpected request: ${input}`);
|
||||
});
|
||||
const request = vi.fn(async () => ({
|
||||
user: {
|
||||
const gql = vi.fn(async () => ({
|
||||
currentUser: {
|
||||
id: 'u1',
|
||||
email: 'u1@affine.pro',
|
||||
name: 'User',
|
||||
@@ -101,7 +101,7 @@ describe('AuthStore', () => {
|
||||
features: ['Admin'],
|
||||
},
|
||||
}));
|
||||
const store = createStore({ fetch, request });
|
||||
const store = createStore({ fetch, gql });
|
||||
|
||||
await expect(store.fetchSession()).resolves.toEqual({
|
||||
user: {
|
||||
@@ -115,17 +115,17 @@ describe('AuthStore', () => {
|
||||
authMethods,
|
||||
},
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith('user.profile.get', {});
|
||||
expect(gql).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('rejects mismatched realtime profile and auth session', async () => {
|
||||
test('rejects mismatched GraphQL profile and auth session', async () => {
|
||||
const fetch = vi.fn(async () => {
|
||||
return {
|
||||
json: async () => ({ user: { id: 'u1' } }),
|
||||
} as Response;
|
||||
});
|
||||
const request = vi.fn(async () => ({
|
||||
user: {
|
||||
const gql = vi.fn(async () => ({
|
||||
currentUser: {
|
||||
id: 'u2',
|
||||
email: 'u2@affine.pro',
|
||||
name: 'User',
|
||||
@@ -135,10 +135,10 @@ describe('AuthStore', () => {
|
||||
features: [],
|
||||
},
|
||||
}));
|
||||
const store = createStore({ fetch, request });
|
||||
const store = createStore({ fetch, gql });
|
||||
|
||||
await expect(store.fetchSession()).rejects.toThrow(
|
||||
'Realtime user profile does not match auth session'
|
||||
'User profile does not match auth session'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
deleteAccountMutation,
|
||||
getCurrentUserQuery,
|
||||
removeAvatarMutation,
|
||||
ServerDeploymentType,
|
||||
updateUserProfileMutation,
|
||||
@@ -101,12 +102,11 @@ export class AuthStore extends Store {
|
||||
const session = await this.fetchAuthSession();
|
||||
if (!session.user) return { user: null };
|
||||
|
||||
const { user } = await this.nbstoreService.realtime.request(
|
||||
'user.profile.get',
|
||||
{}
|
||||
);
|
||||
const { currentUser: user } = await this.gqlService.gql({
|
||||
query: getCurrentUserQuery,
|
||||
});
|
||||
if (!user || user.id !== session.user.id) {
|
||||
throw new Error('Realtime user profile does not match auth session');
|
||||
throw new Error('User profile does not match auth session');
|
||||
}
|
||||
const authMethods = await this.fetchAuthMethods();
|
||||
return { user: { ...user, authMethods } };
|
||||
|
||||
Reference in New Issue
Block a user