mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-01 01:29:31 +08:00
feat(core): improve login flow (#15219)
#### PR Dependency Tree * **PR #15219** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added secure, automatic auth session token refresh and request replay for expired-token responses across Android, iOS, and Electron. * Updated sign-in flows to manage sessions without returning tokens to the app layer. * Added “Devices” management UI with sign out per device and sign out all. * Enabled support for both Hashcash and Turnstile captcha providers. * **Bug Fixes** * Improved refresh de-duplication, inflight cancellation/clear behavior, and recovery from corrupted/invalid sessions. * **Tests** * Expanded auth-session, refresh/revoke, and replay coverage (Electron unit tests, Android instrumentation tests, iOS auth date parser tests). * **Chores** * Removed CAPTCHA site key from build-time configuration and adjusted CI test execution. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -65,6 +65,9 @@ export function configureDefaultAuthProvider(framework: Framework) {
|
||||
|
||||
if (credential.verifyToken) {
|
||||
headers['x-captcha-token'] = credential.verifyToken;
|
||||
headers['x-captcha-provider'] = credential.challenge
|
||||
? 'hashcash'
|
||||
: 'turnstile';
|
||||
}
|
||||
if (credential.challenge) {
|
||||
headers['x-captcha-challenge'] = credential.challenge;
|
||||
@@ -72,7 +75,10 @@ export function configureDefaultAuthProvider(framework: Framework) {
|
||||
|
||||
const res = await fetchService.fetch('/api/auth/sign-in', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(credential),
|
||||
body: JSON.stringify({
|
||||
email: credential.email,
|
||||
password: credential.password,
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...headers,
|
||||
@@ -94,6 +100,7 @@ export function configureDefaultAuthProvider(framework: Framework) {
|
||||
headers: csrfToken ? { 'x-affine-csrf-token': csrfToken } : undefined,
|
||||
});
|
||||
},
|
||||
async clearSession() {},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export {
|
||||
getSelfHostedServerName,
|
||||
} from './server-name';
|
||||
export { AccessTokenService } from './services/access-token';
|
||||
export { AuthService } from './services/auth';
|
||||
export { AuthService, type DeviceAuthSession } from './services/auth';
|
||||
export { CaptchaService } from './services/captcha';
|
||||
export { DefaultServerService } from './services/default-server';
|
||||
export { DocCreatedByUpdatedBySyncService } from './services/doc-created-by-updated-by-sync';
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface AuthProvider {
|
||||
signInOpenAppSignInCode(code: string): Promise<void>;
|
||||
|
||||
signOut(): Promise<void>;
|
||||
|
||||
clearSession(): Promise<void>;
|
||||
}
|
||||
|
||||
export const AuthProvider = createIdentifier<AuthProvider>('AuthProvider');
|
||||
|
||||
@@ -19,6 +19,28 @@ import { assertSupportedServerVersion } from '../stores/server-config';
|
||||
import type { FetchService } from './fetch';
|
||||
import type { ServerService } from './server';
|
||||
|
||||
export interface DeviceAuthSession {
|
||||
id: string;
|
||||
installationId: string;
|
||||
platform: 'ios' | 'android' | 'electron';
|
||||
deviceName?: string | null;
|
||||
appVersion?: string | null;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
idleExpiresAt: string;
|
||||
absoluteExpiresAt: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
function csrfHeader(): Record<string, string> {
|
||||
if (typeof document === 'undefined') return {};
|
||||
const prefix = 'affine_csrf_token=';
|
||||
const cookie = document.cookie
|
||||
.split('; ')
|
||||
.find(value => value.startsWith(prefix));
|
||||
return cookie ? { 'x-affine-csrf-token': cookie.slice(prefix.length) } : {};
|
||||
}
|
||||
|
||||
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
|
||||
@OnEvent(ServerStarted, e => e.onServerStarted)
|
||||
export class AuthService extends Service {
|
||||
@@ -262,6 +284,42 @@ export class AuthService extends Service {
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
async listDeviceSessions() {
|
||||
const response = await this.fetchService.fetch('/api/auth/sessions');
|
||||
return (await response.json()) as DeviceAuthSession[];
|
||||
}
|
||||
|
||||
async revokeDeviceSession(id: string, current: boolean) {
|
||||
await this.fetchService.fetch(
|
||||
`/api/auth/sessions/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: csrfHeader(),
|
||||
}
|
||||
);
|
||||
if (current) {
|
||||
try {
|
||||
await this.store.clearSession();
|
||||
} finally {
|
||||
this.store.setCachedAuthSession(null);
|
||||
this.session.revalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async revokeAllDeviceSessions() {
|
||||
await this.fetchService.fetch('/api/auth/sessions/revoke-all', {
|
||||
method: 'POST',
|
||||
headers: csrfHeader(),
|
||||
});
|
||||
try {
|
||||
await this.store.clearSession();
|
||||
} finally {
|
||||
this.store.setCachedAuthSession(null);
|
||||
this.session.revalidate();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount() {
|
||||
const res = await this.store.deleteAccount();
|
||||
this.store.setCachedAuthSession(null);
|
||||
@@ -277,6 +335,7 @@ export class AuthService extends Service {
|
||||
captchaHeaders(token: string, challenge?: string) {
|
||||
const headers: Record<string, string> = {
|
||||
'x-captcha-token': token,
|
||||
'x-captcha-provider': challenge ? 'hashcash' : 'turnstile',
|
||||
};
|
||||
|
||||
if (challenge) {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Framework, LiveData } from '@toeverything/infra';
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { ValidatorProvider } from '../provider/validator';
|
||||
import { CaptchaService } from './captcha';
|
||||
import { FetchService } from './fetch';
|
||||
import { ServerService } from './server';
|
||||
|
||||
function createService(
|
||||
response: Record<string, unknown>,
|
||||
validate?: (challenge: string, resource: string) => Promise<string>
|
||||
) {
|
||||
const fetch = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
})
|
||||
);
|
||||
const framework = new Framework();
|
||||
framework.service(ServerService, {
|
||||
server: { features$: new LiveData({ captcha: true }) },
|
||||
} as any);
|
||||
framework.service(FetchService, { fetch } as any);
|
||||
if (validate) framework.impl(ValidatorProvider, { validate });
|
||||
framework.service(CaptchaService, f => {
|
||||
return new CaptchaService(
|
||||
f.get(ServerService),
|
||||
f.get(FetchService),
|
||||
f.getOptional(ValidatorProvider)
|
||||
);
|
||||
});
|
||||
const service = framework.provider().get(CaptchaService);
|
||||
return { fetch, service };
|
||||
}
|
||||
|
||||
describe('CaptchaService', () => {
|
||||
test('mints a Hashcash proof from the server challenge', async () => {
|
||||
const validate = vi.fn(async () => 'hashcash-proof');
|
||||
const { fetch, service } = createService(
|
||||
{
|
||||
provider: 'hashcash',
|
||||
challenge: 'challenge-id',
|
||||
resource: 'resource-id',
|
||||
},
|
||||
validate
|
||||
);
|
||||
|
||||
service.revalidate();
|
||||
await vi.waitFor(() => expect(service.isLoading$.value).toBe(false));
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith('/api/auth/captcha', {
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(validate).toHaveBeenCalledWith('challenge-id', 'resource-id');
|
||||
expect(service.provider$.value).toBe('hashcash');
|
||||
expect(service.challenge$.value).toBe('challenge-id');
|
||||
expect(service.verifyToken$.value).toBe('hashcash-proof');
|
||||
});
|
||||
|
||||
test('uses server-provided Turnstile configuration without Hashcash', async () => {
|
||||
const { service } = createService({
|
||||
provider: 'turnstile',
|
||||
siteKey: 'site-key',
|
||||
action: 'auth-sign-in',
|
||||
});
|
||||
|
||||
service.revalidate();
|
||||
await vi.waitFor(() => expect(service.isLoading$.value).toBe(false));
|
||||
|
||||
expect(service.provider$.value).toBe('turnstile');
|
||||
expect(service.turnstile$.value).toEqual({
|
||||
siteKey: 'site-key',
|
||||
action: 'auth-sign-in',
|
||||
});
|
||||
expect(service.verifyToken$.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,10 @@ export class CaptchaService extends Service {
|
||||
r => r?.captcha || false
|
||||
);
|
||||
challenge$ = new LiveData<string | undefined>(undefined);
|
||||
provider$ = new LiveData<'hashcash' | 'turnstile' | undefined>(undefined);
|
||||
turnstile$ = new LiveData<{ siteKey: string; action: string } | undefined>(
|
||||
undefined
|
||||
);
|
||||
isLoading$ = new LiveData(false);
|
||||
verifyToken$ = new LiveData<string | undefined>(undefined);
|
||||
error$ = new LiveData<any | undefined>(undefined);
|
||||
@@ -33,36 +37,59 @@ export class CaptchaService extends Service {
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(async signal => {
|
||||
if (!this.needCaptcha$.value || !this.validatorProvider) {
|
||||
if (!this.needCaptcha$.value) {
|
||||
return {};
|
||||
}
|
||||
const res = await this.fetchService.fetch('/api/auth/challenge', {
|
||||
const res = await this.fetchService.fetch('/api/auth/captcha', {
|
||||
signal,
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
challenge: string;
|
||||
resource: string;
|
||||
provider: 'hashcash' | 'turnstile';
|
||||
challenge?: string;
|
||||
resource?: string;
|
||||
siteKey?: string;
|
||||
action?: string;
|
||||
};
|
||||
if (!data || !data.challenge || !data.resource) {
|
||||
throw new Error('Invalid challenge');
|
||||
if (data.provider === 'turnstile') {
|
||||
if (!data.siteKey || !data.action) {
|
||||
throw new Error('Invalid Turnstile configuration');
|
||||
}
|
||||
return {
|
||||
provider: data.provider,
|
||||
turnstile: { siteKey: data.siteKey, action: data.action },
|
||||
};
|
||||
}
|
||||
if (
|
||||
data.provider !== 'hashcash' ||
|
||||
!data.challenge ||
|
||||
!data.resource ||
|
||||
!this.validatorProvider
|
||||
) {
|
||||
throw new Error('Invalid Hashcash challenge');
|
||||
}
|
||||
const token = await this.validatorProvider.validate(
|
||||
data.challenge,
|
||||
data.resource
|
||||
);
|
||||
return {
|
||||
provider: data.provider,
|
||||
token,
|
||||
challenge: data.challenge,
|
||||
};
|
||||
}).pipe(
|
||||
tap(({ challenge, token }) => {
|
||||
tap(({ challenge, provider, token, turnstile }) => {
|
||||
this.provider$.next(provider);
|
||||
this.turnstile$.next(turnstile);
|
||||
this.verifyToken$.next(token);
|
||||
this.challenge$.next(challenge);
|
||||
this.resetAfter5min();
|
||||
if (token) this.resetAfter5min();
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => {
|
||||
this.error$.next(undefined);
|
||||
this.challenge$.next(undefined);
|
||||
this.provider$.next(undefined);
|
||||
this.turnstile$.next(undefined);
|
||||
this.verifyToken$.next(undefined);
|
||||
this.isLoading$.next(true);
|
||||
}),
|
||||
@@ -81,6 +108,8 @@ export class CaptchaService extends Service {
|
||||
}).pipe(
|
||||
tap(_ => {
|
||||
this.challenge$.next(undefined);
|
||||
this.provider$.next(undefined);
|
||||
this.turnstile$.next(undefined);
|
||||
this.verifyToken$.next(undefined);
|
||||
this.isLoading$.next(false);
|
||||
})
|
||||
|
||||
@@ -59,6 +59,7 @@ export class FetchService extends Service {
|
||||
headers: {
|
||||
...init?.headers,
|
||||
'x-affine-version': BUILD_CONFIG.appVersion,
|
||||
'x-affine-client-kind': BUILD_CONFIG.isNative ? 'native' : 'web',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -137,7 +137,22 @@ export class AuthStore extends Store {
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.authProvider.signOut();
|
||||
try {
|
||||
await this.authProvider.signOut();
|
||||
} finally {
|
||||
await this.deauthenticateRealtime();
|
||||
}
|
||||
}
|
||||
|
||||
async clearSession() {
|
||||
try {
|
||||
await this.authProvider.clearSession();
|
||||
} finally {
|
||||
await this.deauthenticateRealtime();
|
||||
}
|
||||
}
|
||||
|
||||
private async deauthenticateRealtime() {
|
||||
await this.nbstoreService.realtime.configure({
|
||||
endpoint: this.serverService.server.baseUrl,
|
||||
authenticated: false,
|
||||
|
||||
Reference in New Issue
Block a user