feat: add logout and socketio auth

This commit is contained in:
moonrailgun 2024-08-01 01:18:29 +08:00
parent 3afac062c4
commit e9c64c57e7
12 changed files with 124 additions and 76 deletions

View File

@ -161,6 +161,7 @@ export async function signOut<R extends boolean = true>(
method: 'post', method: 'post',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'X-Auth-Return-Redirect': '1',
}, },
// @ts-expect-error // @ts-expect-error
body: new URLSearchParams({ body: new URLSearchParams({

View File

@ -1,20 +1,54 @@
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { signIn } from './lib'; import { signIn, SignInResponse, signOut } from './lib';
import { useUserStore } from '@/store/user';
import { toast } from 'sonner';
import { trpc } from '../trpc';
import { useTranslation } from '@i18next-toolkit/react';
export function useAuth() { export function useAuth() {
const trpcUtils = trpc.useUtils();
const { t } = useTranslation();
const loginWithPassword = useEvent( const loginWithPassword = useEvent(
async (username: string, password: string) => { async (username: string, password: string) => {
const res = await signIn('account', { let res: SignInResponse | undefined;
username, try {
password, res = await signIn('account', {
redirect: false, username,
}); password,
redirect: false,
});
} catch (err) {
toast.error(t('Login failed'));
throw err;
}
return res; if (res?.error) {
toast.error(t('Login failed, please check your username and password'));
throw new Error('Login failed');
}
const userInfo = await trpcUtils.user.info.fetch();
if (!userInfo) {
toast.error(t('Can not get current user info'));
throw new Error('Login failed, ');
}
return userInfo;
} }
); );
const logout = useEvent(async () => {
await signOut({
redirect: false,
});
useUserStore.setState({ info: null });
window.location.href = '/login'; // not good, need to invest to find better way.
});
return { return {
loginWithPassword, loginWithPassword,
logout,
}; };
} }

View File

@ -1,7 +1,4 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useUserStore } from '../../store/user';
import { useEvent } from '../../hooks/useEvent';
import { clearJWT } from '../authjs';
/** /**
* Mock * Mock
@ -10,14 +7,3 @@ import { clearJWT } from '../authjs';
export function getUserTimezone(): string { export function getUserTimezone(): string {
return dayjs.tz.guess() ?? 'utc'; return dayjs.tz.guess() ?? 'utc';
} }
export function useLogout() {
const logout = useEvent(() => {
window.location.href = '/login'; // not good, need to invest to find better way.
useUserStore.setState({ info: null });
clearJWT();
});
return logout;
}

View File

@ -1,7 +1,6 @@
import { message } from 'antd'; import { message } from 'antd';
import axios from 'axios'; import axios from 'axios';
import { get } from 'lodash-es'; import { get } from 'lodash-es';
import { getJWT } from './authjs';
class RequestError extends Error {} class RequestError extends Error {}
@ -9,10 +8,6 @@ function createRequest() {
const ins = axios.create(); const ins = axios.create();
ins.interceptors.request.use(async (val) => { ins.interceptors.request.use(async (val) => {
if (!val.headers.Authorization) {
val.headers.Authorization = `Bearer ${getJWT()}`;
}
return val; return val;
}); });

View File

@ -1,5 +1,4 @@
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { getJWT } from './authjs';
import type { SubscribeEventMap, SocketEventMap } from '../../server/ws/shared'; import type { SubscribeEventMap, SocketEventMap } from '../../server/ws/shared';
import { create } from 'zustand'; import { create } from 'zustand';
import { useEvent } from '../hooks/useEvent'; import { useEvent } from '../hooks/useEvent';
@ -13,13 +12,9 @@ const useSocketStore = create<{
})); }));
export function createSocketIOClient(workspaceId: string) { export function createSocketIOClient(workspaceId: string) {
const token = getJWT();
const socket = io(`/${workspaceId}`, { const socket = io(`/${workspaceId}`, {
transports: ['websocket'], transports: ['websocket'],
reconnectionDelayMax: 10000, reconnectionDelayMax: 10000,
auth: {
token,
},
forceNew: true, forceNew: true,
}); });

View File

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

View File

@ -1,6 +1,5 @@
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'; import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router';
import { useGlobalConfig } from '@/hooks/useConfig'; import { useGlobalConfig } from '@/hooks/useConfig';
import { trpc } from '@/api/trpc';
import { Form, Typography } from 'antd'; import { Form, Typography } from 'antd';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { setUserInfo } from '@/store/user'; import { setUserInfo } from '@/store/user';
@ -8,7 +7,6 @@ import { z } from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useAuth } from '@/api/authjs/useAuth'; import { useAuth } from '@/api/authjs/useAuth';
import { toast } from 'sonner';
import { useEventWithLoading } from '@/hooks/useEvent'; import { useEventWithLoading } from '@/hooks/useEvent';
export const Route = createFileRoute('/login')({ export const Route = createFileRoute('/login')({
@ -30,23 +28,11 @@ function LoginComponent() {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
const search = Route.useSearch(); const search = Route.useSearch();
const trpcUtils = trpc.useUtils();
const { loginWithPassword } = useAuth(); const { loginWithPassword } = useAuth();
const [handleLogin, loading] = useEventWithLoading(async (values: any) => { const [handleLogin, loading] = useEventWithLoading(async (values: any) => {
const res = await loginWithPassword(values.username, values.password); const userInfo = 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); setUserInfo(userInfo);

View File

@ -7,6 +7,7 @@ import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { useAuth } from '@/api/authjs/useAuth';
export const Route = createFileRoute('/register')({ export const Route = createFileRoute('/register')({
component: RegisterComponent, component: RegisterComponent,
@ -16,15 +17,18 @@ function RegisterComponent() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { loginWithPassword } = useAuth();
const mutation = trpc.user.register.useMutation(); const mutation = trpc.user.register.useMutation();
const [{ loading }, handleRegister] = useRequest(async (values: any) => { const [{ loading }, handleRegister] = useRequest(async (values: any) => {
const res = await mutation.mutateAsync({ await mutation.mutateAsync({
username: values.username, username: values.username,
password: values.password, password: values.password,
}); });
setJWT(res.token);
setUserInfo(res.info); const userInfo = await loginWithPassword(values.username, values.password);
setUserInfo(userInfo);
navigate({ navigate({
to: '/', to: '/',

View File

@ -4,13 +4,13 @@ import { useTranslation } from '@i18next-toolkit/react';
import { CommonWrapper } from '@/components/CommonWrapper'; import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Card, Form, Input, Modal, Popconfirm, Typography } from 'antd'; import { Card, Form, Input, Modal, Popconfirm, Typography } from 'antd';
import { useLogout } from '@/api/model/user';
import { trpc, defaultSuccessHandler, defaultErrorHandler } from '@/api/trpc'; import { trpc, defaultSuccessHandler, defaultErrorHandler } from '@/api/trpc';
import { useUserStore } from '@/store/user'; import { useUserStore } from '@/store/user';
import { useState } from 'react'; import { useState } from 'react';
import { CommonHeader } from '@/components/CommonHeader'; import { CommonHeader } from '@/components/CommonHeader';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useAuth } from '@/api/authjs/useAuth';
export const Route = createFileRoute('/settings/profile')({ export const Route = createFileRoute('/settings/profile')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
@ -27,7 +27,7 @@ function PageComponent() {
onError: defaultErrorHandler, onError: defaultErrorHandler,
}); });
const logout = useLogout(); const { logout } = useAuth();
return ( return (
<CommonWrapper header={<CommonHeader title={t('Profile')} />}> <CommonWrapper header={<CommonHeader title={t('Profile')} />}>

View File

@ -1,4 +1,4 @@
import { AuthConfig } from '@auth/core'; import { Auth, AuthConfig, createActionURL } from '@auth/core';
import Nodemailer from '@auth/core/providers/nodemailer'; import Nodemailer from '@auth/core/providers/nodemailer';
import Credentials from '@auth/core/providers/credentials'; import Credentials from '@auth/core/providers/credentials';
import { env } from '../utils/env.js'; import { env } from '../utils/env.js';
@ -16,9 +16,18 @@ import { Theme } from '@auth/core/types';
import { generateSMTPHTML } from '../utils/smtp.js'; import { generateSMTPHTML } from '../utils/smtp.js';
import { SYSTEM_ROLES } from '@tianji/shared'; import { SYSTEM_ROLES } from '@tianji/shared';
import _ from 'lodash'; import _ from 'lodash';
import { IncomingMessage } from 'http';
import { type Session } from '@auth/express';
export interface UserAuthPayload {
id: string;
username: string;
role: string;
}
export const authConfig: Omit<AuthConfig, 'raw'> = { export const authConfig: Omit<AuthConfig, 'raw'> = {
debug: true, debug: env.isProd ? false : true,
basePath: '/api/auth',
providers: [ providers: [
Credentials({ Credentials({
id: 'account', id: 'account',
@ -86,6 +95,42 @@ export const authConfig: Omit<AuthConfig, 'raw'> = {
}, },
}; };
/**
* Pure request of auth session
*/
export async function getAuthSession(
req: IncomingMessage,
secure = false
): Promise<Session> {
const protocol = secure ? 'https:' : 'http:';
const url = createActionURL(
'session',
protocol,
// @ts-expect-error
new Headers(req.headers),
process.env,
authConfig.basePath
);
const response = await Auth(
new Request(url, { headers: { cookie: req.headers.cookie ?? '' } }),
authConfig
);
const { status = 200 } = response;
const data = await response.json();
if (!data || !Object.keys(data).length) {
return null;
}
if (status === 200) {
return data;
}
throw new Error(data.message);
}
function toAdapterUser( function toAdapterUser(
user: Pick< user: Pick<
User, User,

View File

@ -4,6 +4,7 @@ import { jwtVerify } from '../middleware/auth.js';
import { socketEventBus } from './shared.js'; import { socketEventBus } from './shared.js';
import { isCuid } from '../utils/common.js'; import { isCuid } from '../utils/common.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { getAuthSession, UserAuthPayload } from '../model/auth.js';
export function initSocketio(httpServer: HTTPServer) { export function initSocketio(httpServer: HTTPServer) {
const io = new SocketIOServer(httpServer, { const io = new SocketIOServer(httpServer, {
@ -24,27 +25,36 @@ export function initSocketio(httpServer: HTTPServer) {
// Auth // Auth
try { try {
const token = socket.handshake.auth['token']; const token = socket.handshake.auth['token'];
if (typeof token !== 'string') { let user: UserAuthPayload;
throw new Error('Token cannot be empty'); if (token) {
user = jwtVerify(token);
} else {
const session = await getAuthSession(
socket.request,
socket.handshake.secure
);
if (!session) {
throw new Error('Can not get user info.');
}
user = {
id: session.user.id,
username: session.user.name,
role: session.user.role,
};
} }
try { logger.info('[Socket] Authenticated via JWT:', user.id, user.username);
const user = jwtVerify(token);
logger.info('[Socket] Authenticated via JWT:', user.username); socket.data.user = user;
socket.data.token = token;
socket.data.user = user; const workspaceId = socket.nsp.name.replace(/^\//, '');
socket.data.token = token; socket.data.workspaceId = workspaceId;
const workspaceId = socket.nsp.name.replace(/^\//, ''); next();
socket.data.workspaceId = workspaceId;
next();
} catch (err) {
console.error(err);
next(new Error('TokenInvalid'));
}
} catch (err: any) { } catch (err: any) {
console.error('[Socket] Authenticated throw error:', err);
next(err); next(err);
} }
}) })