feat: add support for legacy traditional login methods

This commit is contained in:
moonrailgun 2024-07-31 00:40:04 +08:00
parent 06d6ecd2a3
commit 3afac062c4
24 changed files with 460 additions and 76 deletions

View File

@ -350,6 +350,9 @@ importers:
specifier: ^4.4.1
version: 4.4.1(@types/react@18.2.78)(immer@9.0.21)(react@18.2.0)
devDependencies:
'@auth/core':
specifier: ^0.34.1
version: 0.34.1(nodemailer@6.9.8)
'@i18next-toolkit/cli':
specifier: ^1.2.2
version: 1.2.2(buffer@6.0.3)(typescript@5.5.4)
@ -422,9 +425,6 @@ importers:
'@auth/express':
specifier: ^0.5.5
version: 0.5.6(express@4.18.2)(nodemailer@6.9.8)
'@auth/prisma-adapter':
specifier: ^2.1.0
version: 2.4.1(@prisma/client@5.14.0(prisma@5.14.0))(nodemailer@6.9.8)
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.2.2
@ -1173,11 +1173,6 @@ packages:
peerDependencies:
express: ^4.18.2
'@auth/prisma-adapter@2.4.1':
resolution: {integrity: sha512-VF5IOTHEWHX6WHUxIbsbc12m34cp5T82fzJfi7DmzwBZb89UsoROejV8l0B1dCTZ+pDIS0d4zN9dSa2gIKywNQ==}
peerDependencies:
'@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5'
'@babel/code-frame@7.24.7':
resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==}
engines: {node: '>=6.9.0'}
@ -13990,15 +13985,6 @@ snapshots:
- '@simplewebauthn/server'
- nodemailer
'@auth/prisma-adapter@2.4.1(@prisma/client@5.14.0(prisma@5.14.0))(nodemailer@6.9.8)':
dependencies:
'@auth/core': 0.34.1(nodemailer@6.9.8)
'@prisma/client': 5.14.0(prisma@5.14.0)
transitivePeerDependencies:
- '@simplewebauthn/browser'
- '@simplewebauthn/server'
- nodemailer
'@babel/code-frame@7.24.7':
dependencies:
'@babel/highlight': 7.24.7

View File

@ -43,7 +43,7 @@ const AppRouter: React.FC = React.memo(() => {
<RouterProvider router={router} context={{ userInfo }} />
</TooltipProvider>
<Toaster />
<Toaster position="top-center" />
</BrowserRouter>
);
});

View File

@ -1,15 +1,44 @@
import axios from 'axios';
/**
* @deprecated
*/
const TOKEN_STORAGE_KEY = 'jsonwebtoken';
/**
* @deprecated
*/
export function getJWT(): string | null {
const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
return token ?? null;
}
/**
* @deprecated
*/
export function setJWT(jwt: string) {
window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt);
}
/**
* @deprecated
*/
export function clearJWT() {
window.localStorage.removeItem(TOKEN_STORAGE_KEY);
}
export async function getSession(): Promise<{
user: {
email?: string;
};
expires: string;
} | null> {
const { data } = await axios.get('/api/auth/session');
if (!data) {
return null;
}
return data;
}

View File

@ -0,0 +1,185 @@
/**
* This file is fork from next-auth/react
*/
import type {
ClientSafeProvider,
LiteralUnion,
SignInAuthorizationParams,
SignInOptions,
SignInResponse,
SignOutParams,
SignOutResponse,
} from './types';
import type {
BuiltInProviderType,
RedirectableProviderType,
} from '@auth/core/providers';
import { Session } from '@auth/core/types';
import axios from 'axios';
export * from './types';
type UpdateSession = (data?: any) => Promise<Session | null>;
export type SessionContextValue<R extends boolean = false> = R extends true
?
| { update: UpdateSession; data: Session; status: 'authenticated' }
| { update: UpdateSession; data: null; status: 'loading' }
:
| { update: UpdateSession; data: Session; status: 'authenticated' }
| {
update: UpdateSession;
data: null;
status: 'unauthenticated' | 'loading';
};
/**
* Returns the current Cross Site Request Forgery Token (CSRF Token)
* required to make POST requests (e.g. for signing in and signing out).
* You likely only need to use this if you are not using the built-in
* `signIn()` and `signOut()` methods.
*
* [Documentation](https://next-auth.js.org/getting-started/client#getcsrftoken)
*/
export async function getCsrfToken() {
const { data } = await axios.get<{ csrfToken: string }>('/api/auth/csrf');
return data.csrfToken;
}
/**
* It calls `/api/auth/providers` and returns
* a list of the currently configured authentication providers.
* It can be useful if you are creating a dynamic custom sign in page.
*
* [Documentation](https://next-auth.js.org/getting-started/client#getproviders)
*/
export async function getProviders() {
const { data } = await axios.get<
Record<LiteralUnion<BuiltInProviderType>, ClientSafeProvider>
>('/api/auth/providers');
return data;
}
/**
* Client-side method to initiate a signin flow
* or send the user to the signin page listing all possible providers.
* Automatically adds the CSRF token to the request.
*
* [Documentation](https://next-auth.js.org/getting-started/client#signin)
*/
export async function signIn<
P extends RedirectableProviderType | undefined = undefined,
>(
provider?: LiteralUnion<
P extends RedirectableProviderType
? P | BuiltInProviderType
: BuiltInProviderType
>,
options?: SignInOptions,
authorizationParams?: SignInAuthorizationParams
): Promise<
P extends RedirectableProviderType ? SignInResponse | undefined : undefined
> {
const { callbackUrl = window.location.href, redirect = true } = options ?? {};
const baseUrl = '/api/auth';
const providers = await getProviders();
if (!providers) {
window.location.href = `${baseUrl}/error`;
return;
}
if (!provider || !(provider in providers)) {
window.location.href = `${baseUrl}/signin?${new URLSearchParams({
callbackUrl,
})}`;
return;
}
const isCredentials = providers[provider].type === 'credentials';
const isEmail = providers[provider].type === 'email';
const isSupportingReturn = isCredentials || isEmail;
const signInUrl = `${baseUrl}/${
isCredentials ? 'callback' : 'signin'
}/${provider}`;
const _signInUrl = `${signInUrl}${authorizationParams ? `?${new URLSearchParams(authorizationParams)}` : ''}`;
const res = await fetch(_signInUrl, {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1',
},
// @ts-expect-error
body: new URLSearchParams({
...options,
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
});
const data = await res.json();
// TODO: Do not redirect for Credentials and Email providers by default in next major
if (redirect || !isSupportingReturn) {
const url = data.url ?? callbackUrl;
window.location.href = url;
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) window.location.reload();
return;
}
const error = new URL(data.url).searchParams.get('error');
return {
error,
status: res.status,
ok: res.ok,
url: error ? null : data.url,
} as any;
}
/**
* Signs the user out, by removing the session cookie.
* Automatically adds the CSRF token to the request.
*
* [Documentation](https://next-auth.js.org/getting-started/client#signout)
*/
export async function signOut<R extends boolean = true>(
options?: SignOutParams<R>
): Promise<R extends true ? undefined : SignOutResponse> {
const { callbackUrl = window.location.href } = options ?? {};
const baseUrl = '/api/auth';
const fetchOptions = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
// @ts-expect-error
body: new URLSearchParams({
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
};
const res = await fetch(`${baseUrl}/signout`, fetchOptions);
const data = await res.json();
if (options?.redirect ?? true) {
const url = data.url ?? callbackUrl;
window.location.href = url;
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) window.location.reload();
// @ts-expect-error
return;
}
return data;
}

View File

@ -0,0 +1,55 @@
import type { Session } from '@auth/core/types';
import type { BuiltInProviderType, ProviderType } from '@auth/core/providers';
/**
* Util type that matches some strings literally, but allows any other string as well.
* @source https://github.com/microsoft/TypeScript/issues/29729#issuecomment-832522611
*/
export type LiteralUnion<T extends U, U = string> =
| T
| (U & Record<never, never>);
export interface ClientSafeProvider {
id: LiteralUnion<BuiltInProviderType>;
name: string;
type: ProviderType;
signinUrl: string;
callbackUrl: string;
}
export interface SignInOptions extends Record<string, unknown> {
/**
* Specify to which URL the user will be redirected after signing in. Defaults to the page URL the sign-in is initiated from.
*
* [Documentation](https://next-auth.js.org/getting-started/client#specifying-a-callbackurl)
*/
callbackUrl?: string;
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option) */
redirect?: boolean;
}
export interface SignInResponse {
error: string | null;
status: number;
ok: boolean;
url: string | null;
}
/** Match `inputType` of `new URLSearchParams(inputType)` */
export type SignInAuthorizationParams =
| string
| string[][]
| Record<string, string>
| URLSearchParams;
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1) */
export interface SignOutResponse {
url: string;
}
export interface SignOutParams<R extends boolean = true> {
/** [Documentation](https://next-auth.js.org/getting-started/client#specifying-a-callbackurl-1) */
callbackUrl?: string;
/** [Documentation](https://next-auth.js.org/getting-started/client#using-the-redirect-false-option-1 */
redirect?: R;
}

View File

@ -0,0 +1,20 @@
import { useEvent } from '@/hooks/useEvent';
import { signIn } from './lib';
export function useAuth() {
const loginWithPassword = useEvent(
async (username: string, password: string) => {
const res = await signIn('account', {
username,
password,
redirect: false,
});
return res;
}
);
return {
loginWithPassword,
};
}

View File

@ -1,7 +1,7 @@
import dayjs from 'dayjs';
import { useUserStore } from '../../store/user';
import { useEvent } from '../../hooks/useEvent';
import { clearJWT } from '../auth';
import { clearJWT } from '../authjs';
/**
* Mock

View File

@ -1,7 +1,7 @@
import { message } from 'antd';
import axios from 'axios';
import { get } from 'lodash-es';
import { getJWT } from './auth';
import { getJWT } from './authjs';
class RequestError extends Error {}

View File

@ -1,5 +1,5 @@
import { io, Socket } from 'socket.io-client';
import { getJWT } from './auth';
import { getJWT } from './authjs';
import type { SubscribeEventMap, SocketEventMap } from '../../server/ws/shared';
import { create } from 'zustand';
import { useEvent } from '../hooks/useEvent';

View File

@ -8,7 +8,7 @@ import {
splitLink,
TRPCClientErrorLike,
} from '@trpc/client';
import { getJWT } from './auth';
import { getJWT } from './authjs';
import { message } from 'antd';
import { isDev } from '../utils/env';
@ -22,9 +22,14 @@ export type AppRouterOutput = inferRouterOutputs<AppRouter>;
const url = '/trpc';
function headers() {
const jwt = getJWT();
if (jwt) {
return {
Authorization: `Bearer ${getJWT()}`,
};
}
return {};
}
export const trpcClient = trpc.createClient({

View File

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react';
import { getJWT, setJWT } from '../api/auth';
import { Loading } from './Loading';
import { trpc } from '../api/trpc';
import { setUserInfo } from '../store/user';
@ -7,26 +6,19 @@ import { setUserInfo } from '../store/user';
export const TokenLoginContainer: React.FC<React.PropsWithChildren> =
React.memo((props) => {
const [loading, setLoading] = useState(true);
const mutation = trpc.user.loginWithToken.useMutation();
const trpcUtils = trpc.useUtils();
useEffect(() => {
const token = getJWT();
if (token) {
mutation
.mutateAsync({
token,
trpcUtils.user.info
.fetch()
.then((userInfo) => {
if (userInfo) {
setUserInfo(userInfo);
}
})
.then((res) => {
setJWT(res.token);
setUserInfo(res.info);
})
.catch((err) => {})
.finally(() => {
setLoading(false);
});
} else {
setLoading(false);
}
}, []);
if (loading) {

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
export function useOnline() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : false
);
const setOnline = () => setIsOnline(true);
const setOffline = () => setIsOnline(false);
useEffect(() => {
window.addEventListener('online', setOnline);
window.addEventListener('offline', setOffline);
return () => {
window.removeEventListener('online', setOnline);
window.removeEventListener('offline', setOffline);
};
}, []);
return isOnline;
}

View File

@ -98,6 +98,7 @@
"zustand": "^4.4.1"
},
"devDependencies": {
"@auth/core": "^0.34.1",
"@i18next-toolkit/cli": "^1.2.2",
"@tanstack/router-vite-plugin": "^1.20.5",
"@types/jsonexport": "^3.0.5",

View File

@ -1,6 +1,4 @@
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import { useRequest } from '@/hooks/useRequest';
import { setJWT } from '@/api/auth';
import { useGlobalConfig } from '@/hooks/useConfig';
import { trpc } from '@/api/trpc';
import { Form, Typography } from 'antd';
@ -9,6 +7,9 @@ import { setUserInfo } from '@/store/user';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useAuth } from '@/api/authjs/useAuth';
import { toast } from 'sonner';
import { useEventWithLoading } from '@/hooks/useEvent';
export const Route = createFileRoute('/login')({
validateSearch: z.object({
@ -28,17 +29,27 @@ export const Route = createFileRoute('/login')({
function LoginComponent() {
const navigate = useNavigate();
const { t } = useTranslation();
const loginMutation = trpc.user.login.useMutation();
const search = Route.useSearch();
const trpcUtils = trpc.useUtils();
const [{ loading }, handleLogin] = useRequest(async (values: any) => {
const res = await loginMutation.mutateAsync({
username: values.username,
password: values.password,
});
const { loginWithPassword } = useAuth();
const [handleLogin, loading] = useEventWithLoading(async (values: any) => {
const res = await loginWithPassword(values.username, values.password);
if (res?.error) {
toast.error(t('Login failed, please check your username and password'));
return;
}
const userInfo = await trpcUtils.user.info.fetch();
if (!userInfo) {
toast.error(t('Can not get current user info'));
return;
}
setUserInfo(userInfo);
setJWT(res.token);
setUserInfo(res.info);
navigate({
to: search.redirect ?? '/',
replace: true,

View File

@ -1,7 +1,7 @@
import { Form, Typography } from 'antd';
import { useRequest } from '../hooks/useRequest';
import { trpc } from '../api/trpc';
import { setJWT } from '../api/auth';
import { setJWT } from '../api/authjs';
import { setUserInfo } from '../store/user';
import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router';

View File

@ -2,7 +2,7 @@ import { create } from 'zustand';
import { createSocketIOClient } from '../api/socketio';
import { AppRouterOutput } from '../api/trpc';
type UserLoginInfo = AppRouterOutput['user']['loginWithToken']['info'];
type UserLoginInfo = NonNullable<AppRouterOutput['user']['info']>;
interface UserState {
info: UserLoginInfo | null;
@ -17,7 +17,6 @@ export function setUserInfo(info: UserLoginInfo) {
// Make sure currentWorkspace existed
info.currentWorkspace = {
...info.workspaces[0].workspace,
dashboardLayout: null,
};
}

View File

@ -33,6 +33,9 @@ export default defineConfig({
'/trpc': {
target: 'http://localhost:12345',
},
'/api/auth/': {
target: 'http://localhost:12345',
},
'/api/workspace': {
target: 'http://localhost:12345',
},

View File

@ -28,11 +28,7 @@ export const userInfoSchema = z.object({
createdAt: z.date(),
updatedAt: z.date(),
deletedAt: z.date().nullable(),
currentWorkspace: workspaceSchema.merge(
z.object({
dashboardLayout: workspaceDashboardLayoutSchema.nullable(),
})
),
currentWorkspace: workspaceSchema,
workspaces: z.array(
z.object({
role: z.string(),

View File

@ -14,11 +14,14 @@ import { authUser, createUserWithAuthjs } from './user.js';
import { createTransport } from 'nodemailer';
import { Theme } from '@auth/core/types';
import { generateSMTPHTML } from '../utils/smtp.js';
import { SYSTEM_ROLES } from '@tianji/shared';
import _ from 'lodash';
export const authConfig: Omit<AuthConfig, 'raw'> = {
debug: true,
providers: [
Credentials({
id: 'account',
name: 'Account',
credentials: {
username: { label: 'Username' },
@ -41,6 +44,7 @@ export const authConfig: Omit<AuthConfig, 'raw'> = {
},
}),
Nodemailer({
id: 'email',
name: 'Email',
...env.auth.email,
async sendVerificationRequest(params) {
@ -63,6 +67,23 @@ export const authConfig: Omit<AuthConfig, 'raw'> = {
],
adapter: TianjiPrismaAdapter(prisma),
secret: env.auth.secret,
session: {
strategy: 'jwt',
},
callbacks: {
jwt({ token }) {
return {
...token,
role: SYSTEM_ROLES.user,
};
},
session({ session, token, user }) {
_.set(session, ['user', 'id'], token.sub);
_.set(session, ['user', 'role'], token.role);
return session;
},
},
};
function toAdapterUser(
@ -132,9 +153,16 @@ function TianjiPrismaAdapter(
where: { sessionToken },
include: { user: true },
});
if (!userAndSession) return null;
if (!userAndSession) {
return null;
}
const { user, ...session } = userAndSession;
return { user, session } as {
return {
user: toAdapterUser(user),
session,
} as {
user: AdapterUser;
session: AdapterSession;
};

View File

@ -36,8 +36,6 @@ const createUserSelect = {
select: {
id: true,
name: true,
dashboardOrder: true,
dashboardLayout: true,
},
},
workspaces: {
@ -184,6 +182,17 @@ export async function createUserWithAuthjs(data: Omit<AdapterUser, 'id'>) {
return user;
}
export async function getUserInfo(userId: string) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: createUserSelect,
});
return user;
}
export async function authUser(username: string, password: string) {
const user = await prisma.user.findUnique({
where: {

View File

@ -7,6 +7,7 @@ import {
createAdminUser,
createUser,
getUserCount,
getUserInfo,
} from '../../model/user.js';
import { jwtSign } from '../../middleware/auth.js';
import { TRPCError } from '@trpc/server';
@ -134,4 +135,10 @@ export const userRouter = router({
return changeUserPassword(userId, oldPassword, newPassword);
}),
info: protectProedure
.input(z.void())
.output(userInfoSchema.nullable())
.query(async ({ input, ctx }) => {
return getUserInfo(ctx.user.id);
}),
});

View File

@ -4,10 +4,12 @@ import { z } from 'zod';
import { jwtVerify } from '../middleware/auth.js';
import { getWorkspaceUser } from '../model/workspace.js';
import { ROLES, SYSTEM_ROLES } from '@tianji/shared';
import type { IncomingMessage } from 'http';
import type { Request } from 'express';
import { OpenApiMeta } from 'trpc-openapi';
import { getSession } from '@auth/express';
import { authConfig } from '../model/auth.js';
export function createContext({ req }: { req: IncomingMessage }) {
export async function createContext({ req }: { req: Request }) {
const authorization = req.headers['authorization'] ?? '';
const token = authorization.replace('Bearer ', '');
@ -24,12 +26,10 @@ export const router = t.router;
export const publicProcedure = t.procedure;
const isUser = middleware(async (opts) => {
// auth with token
const token = opts.ctx.token;
if (!token) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'NoToken' });
}
if (token) {
try {
const user = jwtVerify(token);
@ -41,6 +41,25 @@ const isUser = middleware(async (opts) => {
} catch (err) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'TokenInvalid' });
}
}
// auth with session
const req = opts.ctx.req;
const session = await getSession(req, authConfig);
if (session) {
return opts.next({
ctx: {
user: {
id: session.user?.id,
username: session.user?.name,
role: SYSTEM_ROLES.user,
},
},
});
}
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'No Token or Session' });
});
export const protectProedure = t.procedure.use(isUser);

View File

@ -8,7 +8,6 @@
"moduleResolution": "NodeNext",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"skipLibCheck": true,
"strict": true,
"noEmit": false,

18
src/server/types/@auth/core/index.d.ts vendored Normal file
View File

@ -0,0 +1,18 @@
import type { DefaultSession, User, DefaultJWT } from '@auth/express';
import type { SYSTEM_ROLES } from '@tianji/shared';
declare module '@auth/express' {
interface Session extends DefaultSession {
user: {
id: string;
name: string;
email: string;
image: string;
role: SYSTEM_ROLES;
};
}
export interface JWT {
role: SYSTEM_ROLES;
}
}