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",
"uuid": "^9.0.0",
"vite-express": "^0.10.0",
"yup": "^1.2.0"
"yup": "^1.2.0",
"zustand": "^4.4.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.3",

View File

@ -106,6 +106,9 @@ dependencies:
yup:
specifier: ^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:
'@types/bcryptjs':
@ -1960,7 +1963,6 @@ packages:
/@types/prop-types@15.7.5:
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
dev: true
/@types/qs@6.9.8:
resolution: {integrity: sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg==}
@ -1982,7 +1984,6 @@ packages:
'@types/prop-types': 15.7.5
'@types/scheduler': 0.16.3
csstype: 3.1.2
dev: true
/@types/request-ip@0.0.38:
resolution: {integrity: sha512-1yeq8UuK/tUBqLXRY24gjeFvrSNaGNcOcZLQjHlnuw8iu+qE/vTQ64TUcLWorr607NKLfFakdoYEXXHXrLLKCw==}
@ -1992,7 +1993,6 @@ packages:
/@types/scheduler@0.16.3:
resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==}
dev: true
/@types/send@0.17.1:
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
@ -5856,6 +5856,14 @@ packages:
punycode: 2.3.0
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:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: true
@ -6034,3 +6042,23 @@ packages:
toposort: 2.0.2
type-fest: 2.19.0
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,12 +6,17 @@ import { Monitor } from './pages/Monitor';
import { Website } from './pages/Website';
import { Settings } from './pages/Settings';
import { Servers } from './pages/Servers';
import { useUserStore } from './store/user';
import { Register } from './pages/Register';
function App() {
const { info } = useUserStore();
return (
<div className="App">
<BrowserRouter>
<Routes>
{info && (
<Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/monitor" element={<Monitor />} />
@ -19,12 +24,16 @@ function App() {
<Route path="/servers" element={<Servers />} />
<Route path="/settings" element={<Settings />} />
</Route>
)}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route
path="*"
element={<Navigate to="/dashboard" replace={true} />}
element={
<Navigate to={info ? '/dashboard' : '/login'} replace={true} />
}
/>
</Routes>
</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 React from 'react';
import { useEvent } from '../hooks/useEvent';
import axios from 'axios';
import React, { useEffect } from 'react';
import { model } from '../api/model';
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(() => {
const handleLogin = useEvent(async (values: any) => {
await axios.post('/api/user/login', {
username: values.username,
password: values.password,
const navigate = useNavigate();
const [{ loading }, handleLogin] = useRequest(async (values: any) => {
await model.user.login(values.username, values.password);
navigate('/dashboard');
});
useEffect(() => {
const token = getJWT();
if (token) {
loginWithToken().then(() => {
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}>Tianji</Typography.Title>
<Form layout="vertical" onFinish={handleLogin}>
<Form layout="vertical" disabled={loading} onFinish={handleLogin}>
<Form.Item
label="Username"
name="username"
@ -28,13 +40,31 @@ export const Login: React.FC = React.memo(() => {
name="password"
rules={[{ required: true }]}
>
<Input size="large" />
<Input.Password size="large" />
</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
</Button>
</Form.Item>
<Form.Item>
<Button
size="large"
htmlType="button"
block={true}
onClick={() => {
navigate('/register');
}}
>
Register
</Button>
</Form.Item>
</Form>
</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((err: any, req: any, res: any, next: any) => {
res.status(500);
res.json({ error: err.message });
console.error(err);
res.status(500).json({ message: err.message });
});
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 { Handler } from 'express';
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 jwtAudience = process.env.JWT_AUDIENCE || 'msgbyte.com';
interface JWTPayload {
id: string;
username: string;
role: string;
}
passport.use(
new JwtStrategy(
{
@ -18,7 +24,7 @@ passport.use(
audience: jwtAudience,
},
function (jwt_payload, done) {
findUser(jwt_payload.sub)
findUser(jwt_payload.id)
.then((user) => {
if (user) {
done(null, user);
@ -41,16 +47,33 @@ passport.deserializeUser(function (user: any, cb) {
cb(null, user);
});
export function jwtSign(payload: {}): string {
const token = jwt.sign(payload, jwtSecret, {
export function jwtSign(payload: JWTPayload): string {
const token = jwt.sign(
{
id: payload.id,
username: payload.username,
role: payload.role,
},
jwtSecret,
{
issuer: jwtIssuer,
audience: jwtAudience,
expiresIn: '30d',
});
}
);
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 {
return passport.authenticate('jwt', {
session: false,

View File

@ -18,4 +18,4 @@ export function validate(...validator: ValidationChain[]): Handler {
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 bcryptjs from 'bcryptjs';
import { ROLES } from '../utils/const';
import { jwtVerify } from '../middleware/auth';
async function hashPassword(password: string) {
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
*/
@ -13,7 +50,9 @@ export async function createAdminUser(username: string, password: string) {
const count = await prisma.user.count();
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({
@ -34,9 +73,7 @@ export async function createAdminUser(username: string, password: string) {
],
},
},
include: {
workspaces: true,
},
select: createUserSelect,
});
if (user.workspaces[0]) {
@ -45,10 +82,12 @@ export async function createAdminUser(username: string, password: string) {
id: user.id,
},
data: {
currentWorkspaceId: user.workspaces[0].workspaceId,
currentWorkspaceId: user.workspaces[0].workspace.id,
},
});
}
return user;
}
export async function createUser(username: string, password: string) {
@ -80,9 +119,7 @@ export async function createUser(username: string, password: string) {
],
},
},
include: {
workspaces: true,
},
select: createUserSelect,
});
if (user.workspaces[0]) {
@ -91,26 +128,46 @@ export async function createUser(username: string, password: string) {
id: user.id,
},
data: {
currentWorkspaceId: user.workspaces[0].workspaceId,
currentWorkspaceId: user.workspaces[0].workspace.id,
},
});
}
return user;
}
export async function authUser(username: string, password: string) {
const user = await prisma.user.findUniqueOrThrow({
const user = await prisma.user.findUnique({
where: {
username,
password: await hashPassword(password),
},
select: {
id: true,
username: true,
role: true,
createdAt: true,
updatedAt: true,
deletedAt: true,
select: { ...createUserSelect, password: true },
});
if (!user) {
throw new Error('User not existed');
}
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;

View File

@ -1,6 +1,12 @@
import { Router } from 'express';
import { body, validate } from '../middleware/validate';
import { authUser, createAdminUser } from '../model/user';
import { header, body, validate } from '../middleware/validate';
import {
authUser,
authUserWithToken,
createAdminUser,
createUser,
getUserCount,
} from '../model/user';
import { auth, jwtSign } from '../middleware/auth';
export const userRouter = Router();
@ -16,13 +22,53 @@ userRouter.post(
const user = await authUser(username, password);
const token = jwtSign({
id: user.id,
username: user.username,
role: user.role,
});
const token = jwtSign(user);
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 });
}
);