refactor(server): auth (#5895)

Remove `next-auth` and implement our own Authorization/Authentication system from scratch.

## Server

- [x] tokens
  - [x] function
  - [x] encryption

- [x] AuthController
  - [x] /api/auth/sign-in
  - [x] /api/auth/sign-out
  - [x] /api/auth/session
  - [x] /api/auth/session (WE SUPPORT MULTI-ACCOUNT!)

- [x] OAuthPlugin
  - [x] OAuthController
  - [x] /oauth/login
  - [x] /oauth/callback
  - [x] Providers
    - [x] Google
    - [x] GitHub

## Client

- [x] useSession
- [x] cloudSignIn
- [x] cloudSignOut

## NOTE:

Tests will be adding in the future
This commit is contained in:
liuyi
2024-03-12 10:00:09 +00:00
parent af49e8cc41
commit fb3a0e7b8f
148 changed files with 3407 additions and 2851 deletions

View File

@@ -1,10 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import { useSession } from './use-current-user';
export function useCurrentLoginStatus():
| 'authenticated'
| 'unauthenticated'
| 'loading' {
export function useCurrentLoginStatus() {
const session = useSession();
return session.status;
}

View File

@@ -1,42 +1,83 @@
import { type User } from '@affine/component/auth-components';
import type { DefaultSession, Session } from 'next-auth';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { getSession, useSession } from 'next-auth/react';
import { useEffect, useMemo, useReducer } from 'react';
import { DebugLogger } from '@affine/debug';
import { getBaseUrl } from '@affine/graphql';
import { useMemo, useReducer } from 'react';
import useSWR from 'swr';
import { SessionFetchErrorRightAfterLoginOrSignUp } from '../../unexpected-application-state/errors';
import { useAsyncCallback } from '../affine-async-hooks';
export type CheckedUser = User & {
const logger = new DebugLogger('auth');
interface User {
id: string;
email: string;
name: string;
hasPassword: boolean;
update: ReturnType<typeof useSession>['update'];
avatarUrl: string | null;
emailVerified: string | null;
}
export interface Session {
user?: User | null;
status: 'authenticated' | 'unauthenticated' | 'loading';
reload: () => Promise<void>;
}
export type CheckedUser = Session['user'] & {
update: (changes?: Partial<User>) => void;
};
declare module 'next-auth' {
interface Session {
user: {
name: string;
email: string;
id: string;
hasPassword: boolean;
} & Omit<NonNullable<DefaultSession['user']>, 'name' | 'email'>;
export async function getSession(
url: string = getBaseUrl() + '/api/auth/session'
) {
try {
const res = await fetch(url);
if (res.ok) {
return (await res.json()) as { user?: User | null };
}
logger.error('Failed to fetch session', res.statusText);
return { user: null };
} catch (e) {
logger.error('Failed to fetch session', e);
return { user: null };
}
}
export function useSession(): Session {
const { data, mutate, isLoading } = useSWR('session', () => getSession());
return {
user: data?.user,
status: isLoading
? 'loading'
: data?.user
? 'authenticated'
: 'unauthenticated',
reload: async () => {
return mutate().then(e => {
console.error(e);
});
},
};
}
type UpdateSessionAction =
| {
type: 'update';
payload: Session;
payload?: Partial<User>;
}
| {
type: 'fetchError';
payload: null;
};
function updateSessionReducer(prevState: Session, action: UpdateSessionAction) {
function updateSessionReducer(prevState: User, action: UpdateSessionAction) {
const { type, payload } = action;
switch (type) {
case 'update':
return payload;
return { ...prevState, ...payload };
case 'fetchError':
return prevState;
}
@@ -49,11 +90,11 @@ function updateSessionReducer(prevState: Session, action: UpdateSessionAction) {
* If network error or API response error, it will use the cached value.
*/
export function useCurrentUser(): CheckedUser {
const { data, update } = useSession();
const session = useSession();
const [session, dispatcher] = useReducer(
const [user, dispatcher] = useReducer(
updateSessionReducer,
data,
session.user,
firstSession => {
if (!firstSession) {
// barely possible.
@@ -64,10 +105,10 @@ export function useCurrentUser(): CheckedUser {
() => {
getSession()
.then(session => {
if (session) {
if (session.user) {
dispatcher({
type: 'update',
payload: session,
payload: session.user,
});
}
})
@@ -77,35 +118,30 @@ export function useCurrentUser(): CheckedUser {
}
);
}
return firstSession;
}
);
useEffect(() => {
if (data) {
const update = useAsyncCallback(
async (changes?: Partial<User>) => {
dispatcher({
type: 'update',
payload: data,
payload: changes,
});
} else {
dispatcher({
type: 'fetchError',
payload: null,
});
}
}, [data, update]);
const user = session.user;
await session.reload();
},
[dispatcher, session]
);
return useMemo(() => {
return {
id: user.id,
name: user.name,
email: user.email,
image: user.image,
hasPassword: user?.hasPassword ?? false,
return useMemo(
() => ({
...user,
update,
};
// spread the user object to make sure the hook will not be re-rendered when user ref changed but the properties not.
}, [user.id, user.name, user.email, user.image, user.hasPassword, update]);
}),
// only list the things will change as deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[user.id, user.avatarUrl, user.name, update]
);
}

View File

@@ -1,11 +1,12 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
import { useMemo } from 'react';
import { useSession } from './use-current-user';
export const useDeleteCollectionInfo = () => {
const user = useSession().data?.user;
const { user } = useSession();
return useMemo(
() => (user ? { userName: user.name ?? '', userId: user.id } : null),
() => (user ? { userName: user.name, userId: user.id } : null),
[user]
);
};

View File

@@ -1,5 +1,5 @@
import type { ServerFeature } from '@affine/graphql';
import { serverConfigQuery } from '@affine/graphql';
import { oauthProvidersQuery, serverConfigQuery } from '@affine/graphql';
import type { BareFetcher, Middleware } from 'swr';
import { useQueryImmutable } from '../use-query';
@@ -44,6 +44,21 @@ export const useServerFeatures = (): ServerFeatureRecord => {
}, {} as ServerFeatureRecord);
};
export const useOAuthProviders = () => {
const { data, error } = useQueryImmutable(
{ query: oauthProvidersQuery },
{
use: [errorHandler],
}
);
if (error || !data) {
return [];
}
return data.serverConfig.oauthProviders;
};
export const useServerBaseUrl = () => {
const config = useServerConfig();