feat: user login, loginWithToken and register

This commit is contained in:
moonrailgun 2023-09-03 21:05:22 +08:00
parent 08ef1fea91
commit ef1801f531
16 changed files with 488 additions and 60 deletions

View File

@ -47,7 +47,8 @@
"typescript": "^4.9.5", "typescript": "^4.9.5",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"vite-express": "^0.10.0", "vite-express": "^0.10.0",
"yup": "^1.2.0" "yup": "^1.2.0",
"zustand": "^4.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.3", "@types/bcryptjs": "^2.4.3",

View File

@ -106,6 +106,9 @@ dependencies:
yup: yup:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0 version: 1.2.0
zustand:
specifier: ^4.4.1
version: 4.4.1(@types/react@18.2.21)(react@18.2.0)
devDependencies: devDependencies:
'@types/bcryptjs': '@types/bcryptjs':
@ -1960,7 +1963,6 @@ packages:
/@types/prop-types@15.7.5: /@types/prop-types@15.7.5:
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
dev: true
/@types/qs@6.9.8: /@types/qs@6.9.8:
resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==} resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
@ -1982,7 +1984,6 @@ packages:
'@types/prop-types': 15.7.5 '@types/prop-types': 15.7.5
'@types/scheduler': 0.16.3 '@types/scheduler': 0.16.3
csstype: 3.1.2 csstype: 3.1.2
dev: true
/@types/request-ip@0.0.38: /@types/request-ip@0.0.38:
resolution: {integrity: sha512-1yeq8UuK/tUBqLXRY24gjeFvrSNaGNcOcZLQjHlnuw8iu+qE/vTQ64TUcLWorr607NKLfFakdoYEXXHXrLLKCw==} resolution: {integrity: sha512-1yeq8UuK/tUBqLXRY24gjeFvrSNaGNcOcZLQjHlnuw8iu+qE/vTQ64TUcLWorr607NKLfFakdoYEXXHXrLLKCw==}
@ -1992,7 +1993,6 @@ packages:
/@types/scheduler@0.16.3: /@types/scheduler@0.16.3:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
dev: true
/@types/send@0.17.1: /@types/send@0.17.1:
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==} resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
@ -5856,6 +5856,14 @@ packages:
punycode: 2.3.0 punycode: 2.3.0
dev: false dev: false
/use-sync-external-store@1.2.0(react@18.2.0):
resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
react: 18.2.0
dev: false
/util-deprecate@1.0.2: /util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true dev: true
@ -6034,3 +6042,23 @@ packages:
toposort: 2.0.2 toposort: 2.0.2
type-fest: 2.19.0 type-fest: 2.19.0
dev: false dev: false
/zustand@4.4.1(@types/react@18.2.21)(react@18.2.0):
resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
dependencies:
'@types/react': 18.2.21
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false

View File

@ -6,25 +6,34 @@ import { Monitor } from './pages/Monitor';
import { Website } from './pages/Website'; import { Website } from './pages/Website';
import { Settings } from './pages/Settings'; import { Settings } from './pages/Settings';
import { Servers } from './pages/Servers'; import { Servers } from './pages/Servers';
import { useUserStore } from './store/user';
import { Register } from './pages/Register';
function App() { function App() {
const { info } = useUserStore();
return ( return (
<div className="App"> <div className="App">
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<Layout />}> {info && (
<Route path="/dashboard" element={<Dashboard />} /> <Route element={<Layout />}>
<Route path="/monitor" element={<Monitor />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/website" element={<Website />} /> <Route path="/monitor" element={<Monitor />} />
<Route path="/servers" element={<Servers />} /> <Route path="/website" element={<Website />} />
<Route path="/settings" element={<Settings />} /> <Route path="/servers" element={<Servers />} />
</Route> <Route path="/settings" element={<Settings />} />
</Route>
)}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route <Route
path="*" path="*"
element={<Navigate to="/dashboard" replace={true} />} element={
<Navigate to={info ? '/dashboard' : '/login'} replace={true} />
}
/> />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

11
src/client/api/auth.ts Normal file
View File

@ -0,0 +1,11 @@
const TOKEN_STORAGE_KEY = 'jsonwebtoken';
export function getJWT(): string | null {
const token = window.localStorage.getItem(TOKEN_STORAGE_KEY);
return token ?? null;
}
export function setJWT(jwt: string) {
window.localStorage.setItem(TOKEN_STORAGE_KEY, jwt);
}

View File

@ -0,0 +1,5 @@
import * as user from './user';
export const model = {
user,
};

View File

@ -0,0 +1,32 @@
import { setUserInfo, UserLoginInfo } from '../../store/user';
import { getJWT, setJWT } from '../auth';
import { request } from '../request';
export async function login(username: string, password: string) {
const { data } = await request.post('/api/user/login', {
username,
password,
});
setJWT(data.token);
setUserInfo(data.info as UserLoginInfo);
}
export async function loginWithToken() {
const { data } = await request.post('/api/user/loginWithToken', {
token: getJWT(),
});
setJWT(data.token);
setUserInfo(data.info as UserLoginInfo);
}
export async function register(username: string, password: string) {
const { data } = await request.post('/api/user/register', {
username,
password,
});
setJWT(data.token);
setUserInfo(data.info as UserLoginInfo);
}

76
src/client/api/request.ts Normal file
View File

@ -0,0 +1,76 @@
import { message } from 'antd';
import axios from 'axios';
import { get } from 'lodash-es';
import { getJWT } from './auth';
class RequestError extends Error {}
function createRequest() {
const ins = axios.create();
ins.interceptors.request.use(async (val) => {
if (
['post', 'get'].includes(String(val.method).toLowerCase()) &&
!val.headers.Authorization
) {
val.headers.Authorization = `Bearer ${getJWT()}`;
}
return val;
});
ins.interceptors.response.use(
(val) => {
return val;
},
(err) => {
console.log(err);
const responseData = get(err, 'response.data') ?? {};
let errorMsg: string = responseData.message;
const code: number = responseData.code;
const statusCode = get(err, 'response.header.code');
if (
statusCode === 401 // Unauthorized (jwt expired)
) {
backToLoginPage();
return { data: { result: false, msg: errorMsg, code } };
}
throw new RequestError(errorMsg ?? err.message);
}
);
return ins;
}
const backToLoginPage = (() => {
let timer: number;
return () => {
if (timer) {
// Skip if existed
return;
}
if (
window.location.pathname.startsWith('/login') ||
window.location.pathname.startsWith('/register')
) {
// Skip login page
return;
}
message.warning(
'The account authorization has expired. It will automatically jump to the login page in 2 seconds.'
);
timer = window.setTimeout(() => {
window.clearTimeout(timer);
window.location.href = '/login';
}, 2000);
};
})();
export const request = createRequest();

View File

@ -0,0 +1,28 @@
import { message } from 'antd';
import { useState } from 'react';
import { useEvent } from './useEvent';
export function useRequest<T, P>(queryFn: (...args: P[]) => Promise<T>) {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<T | undefined>(undefined);
const run = useEvent(async (...args: P[]) => {
try {
setLoading(true);
const res = await queryFn(...args);
setData(res);
} catch (err: any) {
message.error(err.message ?? String(err));
} finally {
setLoading(false);
}
});
return [
{
loading,
data,
},
run,
] as const;
}

View File

@ -1,21 +1,33 @@
import { Button, Form, Input, Typography } from 'antd'; import { Button, Form, Input, Typography } from 'antd';
import React from 'react'; import React, { useEffect } from 'react';
import { useEvent } from '../hooks/useEvent'; import { model } from '../api/model';
import axios from 'axios'; import { useNavigate } from 'react-router';
import { loginWithToken } from '../api/model/user';
import { getJWT } from '../api/auth';
import { useRequest } from '../hooks/useRequest';
export const Login: React.FC = React.memo(() => { export const Login: React.FC = React.memo(() => {
const handleLogin = useEvent(async (values: any) => { const navigate = useNavigate();
await axios.post('/api/user/login', {
username: values.username, const [{ loading }, handleLogin] = useRequest(async (values: any) => {
password: values.password, await model.user.login(values.username, values.password);
}); navigate('/dashboard');
}); });
useEffect(() => {
const token = getJWT();
if (token) {
loginWithToken().then(() => {
navigate('/dashboard');
});
}
}, []);
return ( return (
<div className="w-full h-full flex justify-center items-center"> <div className="w-full h-full flex justify-center items-center">
<div className="w-80 -translate-y-1/4"> <div className="w-80 -translate-y-1/4">
<Typography.Title level={2}>Tianji</Typography.Title> <Typography.Title level={2}>Tianji</Typography.Title>
<Form layout="vertical" onFinish={handleLogin}> <Form layout="vertical" disabled={loading} onFinish={handleLogin}>
<Form.Item <Form.Item
label="Username" label="Username"
name="username" name="username"
@ -28,13 +40,31 @@ export const Login: React.FC = React.memo(() => {
name="password" name="password"
rules={[{ required: true }]} rules={[{ required: true }]}
> >
<Input size="large" /> <Input.Password size="large" />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" size="large" htmlType="submit" block={true}> <Button
type="primary"
size="large"
htmlType="submit"
block={true}
loading={loading}
>
Login Login
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item>
<Button
size="large"
htmlType="button"
block={true}
onClick={() => {
navigate('/register');
}}
>
Register
</Button>
</Form.Item>
</Form> </Form>
</div> </div>
</div> </div>

View File

@ -0,0 +1,50 @@
import { Button, Form, Input, Typography } from 'antd';
import React from 'react';
import { model } from '../api/model';
import { useNavigate } from 'react-router';
import { useRequest } from '../hooks/useRequest';
export const Register: React.FC = React.memo(() => {
const navigate = useNavigate();
const [{ loading }, handleRegister] = useRequest(async (values: any) => {
await model.user.register(values.username, values.password);
navigate('/dashboard');
});
return (
<div className="w-full h-full flex justify-center items-center">
<div className="w-80 -translate-y-1/4">
<Typography.Title level={2}>Register Account</Typography.Title>
<Form layout="vertical" disabled={loading} onFinish={handleRegister}>
<Form.Item
label="Username"
name="username"
rules={[{ required: true }]}
>
<Input size="large" />
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[{ required: true }]}
>
<Input.Password size="large" />
</Form.Item>
<Form.Item>
<Button
type="primary"
size="large"
htmlType="submit"
block={true}
loading={loading}
>
Register
</Button>
</Form.Item>
</Form>
</div>
</div>
);
});
Register.displayName = 'Register';

32
src/client/store/user.ts Normal file
View File

@ -0,0 +1,32 @@
import { create } from 'zustand';
export interface UserLoginInfo {
id: string;
username: string;
role: string;
currentWorkspace: {
id: string;
name: string;
};
workspaces: {
role: string;
workspace: {
id: string;
name: string;
};
}[];
}
interface UserState {
info: UserLoginInfo | null;
}
export const useUserStore = create<UserState>(() => ({
info: null,
}));
export function setUserInfo(info: UserInfo) {
useUserStore.setState({
info,
});
}

View File

@ -22,8 +22,8 @@ app.use('/api/user', userRouter);
app.use('/api/website', websiteRouter); app.use('/api/website', websiteRouter);
app.use((err: any, req: any, res: any, next: any) => { app.use((err: any, req: any, res: any, next: any) => {
res.status(500); console.error(err);
res.json({ error: err.message }); res.status(500).json({ message: err.message });
}); });
ViteExpress.listen(app, port, () => { ViteExpress.listen(app, port, () => {

View File

@ -1,4 +1,4 @@
import { authUser, findUser } from '../model/user'; import { findUser } from '../model/user';
import passport from 'passport'; import passport from 'passport';
import { Handler } from 'express'; import { Handler } from 'express';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt'; import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
@ -9,6 +9,12 @@ export const jwtSecret = process.env.JWT_SECRET || nanoid();
export const jwtIssuer = process.env.JWT_ISSUER || 'tianji.msgbyte.com'; export const jwtIssuer = process.env.JWT_ISSUER || 'tianji.msgbyte.com';
export const jwtAudience = process.env.JWT_AUDIENCE || 'msgbyte.com'; export const jwtAudience = process.env.JWT_AUDIENCE || 'msgbyte.com';
interface JWTPayload {
id: string;
username: string;
role: string;
}
passport.use( passport.use(
new JwtStrategy( new JwtStrategy(
{ {
@ -18,7 +24,7 @@ passport.use(
audience: jwtAudience, audience: jwtAudience,
}, },
function (jwt_payload, done) { function (jwt_payload, done) {
findUser(jwt_payload.sub) findUser(jwt_payload.id)
.then((user) => { .then((user) => {
if (user) { if (user) {
done(null, user); done(null, user);
@ -41,16 +47,33 @@ passport.deserializeUser(function (user: any, cb) {
cb(null, user); cb(null, user);
}); });
export function jwtSign(payload: {}): string { export function jwtSign(payload: JWTPayload): string {
const token = jwt.sign(payload, jwtSecret, { const token = jwt.sign(
issuer: jwtIssuer, {
audience: jwtAudience, id: payload.id,
expiresIn: '30d', username: payload.username,
}); role: payload.role,
},
jwtSecret,
{
issuer: jwtIssuer,
audience: jwtAudience,
expiresIn: '30d',
}
);
return token; return token;
} }
export function jwtVerify(token: string): JWTPayload {
const payload = jwt.verify(token, jwtSecret, {
issuer: jwtIssuer,
audience: jwtAudience,
});
return payload as JWTPayload;
}
export function auth(): Handler { export function auth(): Handler {
return passport.authenticate('jwt', { return passport.authenticate('jwt', {
session: false, session: false,

View File

@ -18,4 +18,4 @@ export function validate(...validator: ValidationChain[]): Handler {
return compose([...validator, handler as any]); return compose([...validator, handler as any]);
} }
export { body, query, param } from 'express-validator'; export { body, query, param, header } from 'express-validator';

View File

@ -1,11 +1,48 @@
import { prisma } from './_client'; import { prisma } from './_client';
import bcryptjs from 'bcryptjs'; import bcryptjs from 'bcryptjs';
import { ROLES } from '../utils/const'; import { ROLES } from '../utils/const';
import { jwtVerify } from '../middleware/auth';
async function hashPassword(password: string) { async function hashPassword(password: string) {
return await bcryptjs.hash(password, 10); return await bcryptjs.hash(password, 10);
} }
function comparePassword(password: string, hash: string): Promise<boolean> {
return bcryptjs.compare(password, hash);
}
export async function getUserCount(): Promise<number> {
const count = await prisma.user.count();
return count;
}
const createUserSelect = {
id: true,
username: true,
role: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
currentWorkspace: {
select: {
id: true,
name: true,
},
},
workspaces: {
select: {
role: true,
workspace: {
select: {
id: true,
name: true,
},
},
},
},
};
/** /**
* Create User * Create User
*/ */
@ -13,7 +50,9 @@ export async function createAdminUser(username: string, password: string) {
const count = await prisma.user.count(); const count = await prisma.user.count();
if (count > 0) { if (count > 0) {
throw new Error('Create Admin User Just Only allow in non people exist'); throw new Error(
'Create Admin User Just Only allow in non people exist, you can Grant Privilege with admin user'
);
} }
const user = await prisma.user.create({ const user = await prisma.user.create({
@ -34,9 +73,7 @@ export async function createAdminUser(username: string, password: string) {
], ],
}, },
}, },
include: { select: createUserSelect,
workspaces: true,
},
}); });
if (user.workspaces[0]) { if (user.workspaces[0]) {
@ -45,10 +82,12 @@ export async function createAdminUser(username: string, password: string) {
id: user.id, id: user.id,
}, },
data: { data: {
currentWorkspaceId: user.workspaces[0].workspaceId, currentWorkspaceId: user.workspaces[0].workspace.id,
}, },
}); });
} }
return user;
} }
export async function createUser(username: string, password: string) { export async function createUser(username: string, password: string) {
@ -80,9 +119,7 @@ export async function createUser(username: string, password: string) {
], ],
}, },
}, },
include: { select: createUserSelect,
workspaces: true,
},
}); });
if (user.workspaces[0]) { if (user.workspaces[0]) {
@ -91,26 +128,46 @@ export async function createUser(username: string, password: string) {
id: user.id, id: user.id,
}, },
data: { data: {
currentWorkspaceId: user.workspaces[0].workspaceId, currentWorkspaceId: user.workspaces[0].workspace.id,
}, },
}); });
} }
return user;
} }
export async function authUser(username: string, password: string) { export async function authUser(username: string, password: string) {
const user = await prisma.user.findUniqueOrThrow({ const user = await prisma.user.findUnique({
where: { where: {
username, username,
password: await hashPassword(password),
}, },
select: { select: { ...createUserSelect, password: true },
id: true, });
username: true,
role: true, if (!user) {
createdAt: true, throw new Error('User not existed');
updatedAt: true, }
deletedAt: true,
const checkPassword = await comparePassword(password, user.password);
if (!checkPassword) {
throw new Error('Password incorrected');
}
delete (user as any)['password'];
return user;
}
export async function authUserWithToken(token: string) {
const payload = jwtVerify(token);
const id = payload.id;
const user = await prisma.user.findUniqueOrThrow({
where: {
id,
}, },
select: createUserSelect,
}); });
return user; return user;

View File

@ -1,6 +1,12 @@
import { Router } from 'express'; import { Router } from 'express';
import { body, validate } from '../middleware/validate'; import { header, body, validate } from '../middleware/validate';
import { authUser, createAdminUser } from '../model/user'; import {
authUser,
authUserWithToken,
createAdminUser,
createUser,
getUserCount,
} from '../model/user';
import { auth, jwtSign } from '../middleware/auth'; import { auth, jwtSign } from '../middleware/auth';
export const userRouter = Router(); export const userRouter = Router();
@ -16,13 +22,53 @@ userRouter.post(
const user = await authUser(username, password); const user = await authUser(username, password);
const token = jwtSign({ const token = jwtSign(user);
id: user.id,
username: user.username,
role: user.role,
});
res.json({ token }); res.json({ info: user, token });
}
);
userRouter.post(
'/register',
validate(
body('username').exists().withMessage('Username should be existed'),
body('password').exists().withMessage('Password should be existed')
),
async (req, res) => {
const { username, password } = req.body;
const userCount = await getUserCount();
if (userCount === 0) {
const user = await createAdminUser(username, password);
const token = jwtSign(user);
res.json({ info: user, token });
} else {
const user = await createUser(username, password);
const token = jwtSign(user);
res.json({ info: user, token });
}
}
);
userRouter.post(
'/loginWithToken',
validate(body('token').exists().withMessage('Token should be existed')),
async (req, res) => {
const { token } = req.body;
if (!token) {
throw new Error('Cannot get token');
}
const user = await authUserWithToken(token);
const newToken = jwtSign(user);
res.json({ info: user, token: newToken });
} }
); );