mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-16 17:46:18 +08:00
feat(server): passkey pre-refactor (#15060)
#### PR Dependency Tree * **PR #15060** 👈 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** * OpenApp native sign-in and native session exchange (JWT) for mobile & desktop. * Centralized short-lived auth challenge store for one-time tokens. * Encrypted per-endpoint token storage and native token handlers (Android, iOS, Electron). * **Improvements** * Richer auth-method reporting (password, magic link, OAuth, passkey) and improved sign-in flows. * Hardened magic-link, OAuth, and session issuance; JWT-backed sessions and websocket JWT support. * UX tweaks: form-based password submit, OTP autocomplete, adjusted captcha flow. * **Bug Fixes** * Expanded tests and auth-state resets to avoid cross-test leakage. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -41,7 +41,6 @@ describe('AuthService oauthPreflight', () => {
|
||||
framework.service(NbstoreService, {
|
||||
realtime: { subscribe: () => of() },
|
||||
} as any);
|
||||
|
||||
framework.service(AuthService, [
|
||||
FetchService,
|
||||
AuthStore,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const Captcha = () => {
|
||||
|
||||
const handleTurnstileSuccess = useCallback(
|
||||
(token: string) => {
|
||||
captchaService.challenge$.next(undefined);
|
||||
captchaService.verifyToken$.next(token);
|
||||
},
|
||||
[captchaService]
|
||||
|
||||
@@ -86,7 +86,6 @@ export const SignInWithEmailStep = ({
|
||||
setIsSending(true);
|
||||
try {
|
||||
setResendCountDown(60);
|
||||
captchaService.revalidate();
|
||||
await authService.sendEmailMagicLink(
|
||||
email,
|
||||
verifyToken,
|
||||
@@ -100,6 +99,7 @@ export const SignInWithEmailStep = ({
|
||||
title: 'Failed to sign in',
|
||||
message: t[`error.${error.name}`](error.data),
|
||||
});
|
||||
captchaService.revalidate();
|
||||
}
|
||||
setIsSending(false);
|
||||
}, [
|
||||
@@ -182,6 +182,7 @@ export const SignInWithEmailStep = ({
|
||||
errorHint={otpError}
|
||||
onEnter={onContinue}
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
required={true}
|
||||
maxLength={6}
|
||||
/>
|
||||
|
||||
@@ -85,7 +85,6 @@ export const SignInWithPasswordStep = ({
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
captchaService.revalidate();
|
||||
await authService.signInPassword({
|
||||
email,
|
||||
password,
|
||||
@@ -111,6 +110,7 @@ export const SignInWithPasswordStep = ({
|
||||
: t[`error.${error.name}`](error.data),
|
||||
});
|
||||
}
|
||||
captchaService.revalidate();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -138,28 +138,50 @@ export const SignInWithPasswordStep = ({
|
||||
/>
|
||||
|
||||
<AuthContent>
|
||||
<AuthInput
|
||||
label={t['com.affine.settings.email']()}
|
||||
disabled={true}
|
||||
value={email}
|
||||
/>
|
||||
<AuthInput
|
||||
autoFocus
|
||||
data-testid="password-input"
|
||||
label={t['com.affine.auth.password']()}
|
||||
value={password}
|
||||
type="password"
|
||||
onChange={(value: string) => {
|
||||
setPassword(value);
|
||||
if (passwordError) {
|
||||
setPasswordError(false);
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
}
|
||||
<form
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
onSignIn();
|
||||
}}
|
||||
error={passwordError}
|
||||
errorHint={passwordErrorHint}
|
||||
onEnter={onSignIn}
|
||||
/>
|
||||
>
|
||||
<AuthInput
|
||||
label={t['com.affine.settings.email']()}
|
||||
readOnly={true}
|
||||
value={email}
|
||||
type="email"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<AuthInput
|
||||
autoFocus
|
||||
data-testid="password-input"
|
||||
label={t['com.affine.auth.password']()}
|
||||
value={password}
|
||||
type="password"
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
onChange={(value: string) => {
|
||||
setPassword(value);
|
||||
if (passwordError) {
|
||||
setPasswordError(false);
|
||||
setPasswordErrorHint(t['com.affine.auth.password.error']());
|
||||
}
|
||||
}}
|
||||
error={passwordError}
|
||||
errorHint={passwordErrorHint}
|
||||
onEnter={onSignIn}
|
||||
/>
|
||||
{!verifyToken && needCaptcha && <Captcha />}
|
||||
<Button
|
||||
data-testid="sign-in-button"
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
style={{ width: '100%' }}
|
||||
disabled={isLoading || (!verifyToken && needCaptcha)}
|
||||
>
|
||||
{t['com.affine.auth.sign.in']()}
|
||||
</Button>
|
||||
</form>
|
||||
{!isSelfhosted && (
|
||||
<div className={styles.passwordButtonRow}>
|
||||
<a
|
||||
@@ -171,17 +193,6 @@ export const SignInWithPasswordStep = ({
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{!verifyToken && needCaptcha && <Captcha />}
|
||||
<Button
|
||||
data-testid="sign-in-button"
|
||||
variant="primary"
|
||||
size="extraLarge"
|
||||
style={{ width: '100%' }}
|
||||
disabled={isLoading || (!verifyToken && needCaptcha)}
|
||||
onClick={onSignIn}
|
||||
>
|
||||
{t['com.affine.auth.sign.in']()}
|
||||
</Button>
|
||||
</AuthContent>
|
||||
<AuthFooter>
|
||||
<Back changeState={changeState} />
|
||||
|
||||
@@ -90,7 +90,9 @@ export const SignInStep = ({
|
||||
setIsMutating(true);
|
||||
|
||||
try {
|
||||
const { hasPassword } = await authService.checkUserByEmail(email);
|
||||
const { methods } = await authService.checkUserByEmail(email);
|
||||
const hasPassword = methods.password.available;
|
||||
const canUseMagicLink = methods.magicLink.available;
|
||||
|
||||
if (hasPassword) {
|
||||
changeState(prev => ({
|
||||
@@ -99,13 +101,18 @@ export const SignInStep = ({
|
||||
step: 'signInWithPassword',
|
||||
hasPassword: true,
|
||||
}));
|
||||
} else {
|
||||
} else if (canUseMagicLink) {
|
||||
changeState(prev => ({
|
||||
...prev,
|
||||
email,
|
||||
step: 'signInWithEmail',
|
||||
hasPassword: false,
|
||||
}));
|
||||
} else {
|
||||
notify.error({
|
||||
title: 'Failed to sign in',
|
||||
message: 'This email is not available for sign in.',
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
@@ -151,31 +158,41 @@ export const SignInStep = ({
|
||||
<AuthContent>
|
||||
<OAuth redirectUrl={state.redirectUrl} />
|
||||
|
||||
<AuthInput
|
||||
className={style.authInput}
|
||||
label={t['com.affine.settings.email']()}
|
||||
placeholder={t['com.affine.auth.sign.email.placeholder']()}
|
||||
onChange={setEmail}
|
||||
error={!isValidEmail}
|
||||
errorHint={
|
||||
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
|
||||
}
|
||||
onEnter={onContinue}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={style.signInButton}
|
||||
style={{ width: '100%' }}
|
||||
size="extraLarge"
|
||||
data-testid="continue-login-button"
|
||||
block
|
||||
loading={isMutating}
|
||||
suffix={<ArrowRightBigIcon />}
|
||||
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
|
||||
onClick={onContinue}
|
||||
<form
|
||||
onSubmit={event => {
|
||||
event.preventDefault();
|
||||
onContinue();
|
||||
}}
|
||||
>
|
||||
{t['com.affine.auth.sign.email.continue']()}
|
||||
</Button>
|
||||
<AuthInput
|
||||
className={style.authInput}
|
||||
label={t['com.affine.settings.email']()}
|
||||
placeholder={t['com.affine.auth.sign.email.placeholder']()}
|
||||
onChange={setEmail}
|
||||
error={!isValidEmail}
|
||||
errorHint={
|
||||
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
|
||||
}
|
||||
onEnter={onContinue}
|
||||
type="email"
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
/>
|
||||
|
||||
<Button
|
||||
className={style.signInButton}
|
||||
style={{ width: '100%' }}
|
||||
size="extraLarge"
|
||||
data-testid="continue-login-button"
|
||||
block
|
||||
loading={isMutating}
|
||||
disabled={isMutating}
|
||||
suffix={<ArrowRightBigIcon />}
|
||||
suffixStyle={{ width: 20, height: 20, color: cssVar('blue') }}
|
||||
>
|
||||
{t['com.affine.auth.sign.email.continue']()}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{!isSelfhosted && (
|
||||
<>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
export const ChangePasswordDialog = ({
|
||||
close,
|
||||
hasPassword: hasPasswordProp,
|
||||
server: serverBaseUrl,
|
||||
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['change-password']>) => {
|
||||
const t = useI18n();
|
||||
@@ -44,7 +45,8 @@ export const ChangePasswordDialog = ({
|
||||
const authService = server.scope.get(AuthService);
|
||||
const account = useLiveData(authService.session.account$);
|
||||
const email = account?.email;
|
||||
const hasPassword = account?.info?.hasPassword;
|
||||
const hasPassword =
|
||||
hasPasswordProp ?? account?.info?.authMethods?.password.bound ?? false;
|
||||
const [hasSentEmail, setHasSentEmail] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const passwordLimits = useLiveData(
|
||||
|
||||
@@ -201,13 +201,19 @@ export const AccountSetting = ({
|
||||
|
||||
const onPasswordButtonClick = useCallback(() => {
|
||||
globalDialogService.open('change-password', {
|
||||
hasPassword: account?.info?.authMethods?.password.bound,
|
||||
server: serverService.server.baseUrl,
|
||||
});
|
||||
}, [globalDialogService, serverService.server.baseUrl]);
|
||||
}, [
|
||||
account?.info?.authMethods?.password.bound,
|
||||
globalDialogService,
|
||||
serverService.server.baseUrl,
|
||||
]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
const hasPassword = account.info?.authMethods?.password.bound;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -233,7 +239,7 @@ export const AccountSetting = ({
|
||||
desc={t['com.affine.settings.password.message']()}
|
||||
>
|
||||
<Button onClick={onPasswordButtonClick}>
|
||||
{account.info?.hasPassword
|
||||
{hasPassword
|
||||
? t['com.affine.settings.password.action.change']()
|
||||
: t['com.affine.settings.password.action.set']()}
|
||||
</Button>
|
||||
|
||||
@@ -79,6 +79,13 @@ export function configureDefaultAuthProvider(framework: Framework) {
|
||||
},
|
||||
});
|
||||
},
|
||||
async signInOpenAppSignInCode(code: string) {
|
||||
await fetchService.fetch('/api/auth/open-app/sign-in', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
},
|
||||
async signOut() {
|
||||
const csrfToken = getCookieValue(CSRF_COOKIE_NAME);
|
||||
await fetchService.fetch('/api/auth/sign-out', {
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface AuthProvider {
|
||||
challenge?: string;
|
||||
}): Promise<void>;
|
||||
|
||||
signInOpenAppSignInCode(code: string): Promise<void>;
|
||||
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
@@ -222,11 +222,7 @@ export class AuthService extends Service {
|
||||
}
|
||||
|
||||
async signInOpenAppSignInCode(code: string) {
|
||||
await this.fetchService.fetch('/api/auth/open-app/sign-in', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
await this.store.signInOpenAppSignInCode(code);
|
||||
|
||||
this.session.revalidate();
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class CaptchaService extends Service {
|
||||
revalidate = effect(
|
||||
exhaustMap(() => {
|
||||
return fromPromise(async signal => {
|
||||
if (!this.needCaptcha$.value) {
|
||||
if (!this.needCaptcha$.value || !this.validatorProvider) {
|
||||
return {};
|
||||
}
|
||||
const res = await this.fetchService.fetch('/api/auth/challenge', {
|
||||
@@ -46,17 +46,14 @@ export class CaptchaService extends Service {
|
||||
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 };
|
||||
const token = await this.validatorProvider.validate(
|
||||
data.challenge,
|
||||
data.resource
|
||||
);
|
||||
return {
|
||||
token,
|
||||
challenge: data.challenge,
|
||||
};
|
||||
}).pipe(
|
||||
tap(({ challenge, token }) => {
|
||||
this.verifyToken$.next(token);
|
||||
|
||||
@@ -19,6 +19,11 @@ export interface AccountProfile {
|
||||
email: string;
|
||||
name: string;
|
||||
hasPassword: boolean;
|
||||
authMethods?: {
|
||||
password: { bound: boolean };
|
||||
oauth: { bound: boolean; providers: string[] };
|
||||
passkey: { bound: boolean; count: number };
|
||||
};
|
||||
avatarUrl: string | null;
|
||||
emailVerified: string | null;
|
||||
features?: string[];
|
||||
@@ -61,16 +66,20 @@ export class AuthStore extends Store {
|
||||
}
|
||||
|
||||
async fetchSession() {
|
||||
const { user } = await this.nbstoreService.realtime.request(
|
||||
'user.profile.get',
|
||||
{},
|
||||
{ timeoutMs: 10000 }
|
||||
);
|
||||
const { user } = await this.fetchService
|
||||
.fetch('/api/auth/session')
|
||||
.then(res => res.json());
|
||||
const authMethods = user
|
||||
? await this.fetchService
|
||||
.fetch('/api/auth/methods')
|
||||
.then(res => (res.ok ? res.json() : undefined))
|
||||
: undefined;
|
||||
return {
|
||||
user: user
|
||||
? {
|
||||
...user,
|
||||
hasPassword: Boolean(user.hasPassword),
|
||||
authMethods,
|
||||
emailVerified: user.emailVerified ? 'true' : null,
|
||||
}
|
||||
: null,
|
||||
@@ -103,6 +112,10 @@ export class AuthStore extends Store {
|
||||
await this.authProvider.signInPassword(credential);
|
||||
}
|
||||
|
||||
async signInOpenAppSignInCode(code: string) {
|
||||
await this.authProvider.signInOpenAppSignInCode(code);
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.authProvider.signOut();
|
||||
await this.nbstoreService.realtime.configure({
|
||||
@@ -155,8 +168,12 @@ export class AuthStore extends Store {
|
||||
|
||||
const data = (await res.json()) as {
|
||||
registered: boolean;
|
||||
hasPassword: boolean;
|
||||
magicLink: boolean;
|
||||
methods: {
|
||||
password: { available: boolean };
|
||||
magicLink: { available: boolean };
|
||||
oauth: { available: boolean; providers: string[] };
|
||||
passkey: { available: boolean; discoverable: boolean };
|
||||
};
|
||||
};
|
||||
|
||||
return data;
|
||||
|
||||
@@ -30,7 +30,10 @@ export type GLOBAL_DIALOG_SCHEMA = {
|
||||
snapshotUrl: string;
|
||||
}) => void;
|
||||
'sign-in': (props: { server?: string; step?: string }) => void;
|
||||
'change-password': (props: { server?: string }) => void;
|
||||
'change-password': (props: {
|
||||
server?: string;
|
||||
hasPassword?: boolean;
|
||||
}) => void;
|
||||
'verify-email': (props: { server?: string; changeEmail?: boolean }) => void;
|
||||
'enable-cloud': (props: {
|
||||
workspaceId: string;
|
||||
|
||||
Reference in New Issue
Block a user