diff --git a/back/api/auth.ts b/back/api/auth.ts index 683bf3b8..67e08ec9 100644 --- a/back/api/auth.ts +++ b/back/api/auth.ts @@ -80,10 +80,14 @@ export default (app: Router) => { async (req: Request, res: Response, next: NextFunction) => { const logger: Logger = Container.get('logger'); try { - fs.readFile(config.authConfigFile, 'utf8', (err, data) => { - if (err) console.log(err); - const authInfo = JSON.parse(data); - res.send({ code: 200, data: { username: authInfo.username } }); + const authService = Container.get(AuthService); + const authInfo = await authService.getUserInfo(); + res.send({ + code: 200, + data: { + username: authInfo.username, + twoFactorActived: authInfo.twoFactorActived, + }, }); } catch (e) { logger.error('🔥 error: %o', e); @@ -91,4 +95,76 @@ export default (app: Router) => { } }, ); + + route.get( + '/user/two-factor/init', + async (req: Request, res: Response, next: NextFunction) => { + const logger: Logger = Container.get('logger'); + try { + const authService = Container.get(AuthService); + const data = await authService.initTwoFactor(); + res.send({ code: 200, data }); + } catch (e) { + logger.error('🔥 error: %o', e); + return next(e); + } + }, + ); + + route.put( + '/user/two-factor/active', + celebrate({ + body: Joi.object({ + code: Joi.string().required(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + const logger: Logger = Container.get('logger'); + try { + const authService = Container.get(AuthService); + const data = await authService.activeTwoFactor(req.body.code); + res.send({ code: 200, data }); + } catch (e) { + logger.error('🔥 error: %o', e); + return next(e); + } + }, + ); + + route.get( + '/user/two-factor/deactive', + async (req: Request, res: Response, next: NextFunction) => { + const logger: Logger = Container.get('logger'); + try { + const authService = Container.get(AuthService); + const data = await authService.deactiveTwoFactor(); + res.send({ code: 200, data }); + } catch (e) { + logger.error('🔥 error: %o', e); + return next(e); + } + }, + ); + + route.put( + '/user/two-factor/login', + celebrate({ + body: Joi.object({ + code: Joi.string().required(), + username: Joi.string().required(), + password: Joi.string().required(), + }), + }), + async (req: Request, res: Response, next: NextFunction) => { + const logger: Logger = Container.get('logger'); + try { + const authService = Container.get(AuthService); + const data = await authService.twoFactorLogin(req.body, req); + res.send(data); + } catch (e) { + logger.error('🔥 error: %o', e); + return next(e); + } + }, + ); }; diff --git a/back/loaders/express.ts b/back/loaders/express.ts index 183e59f8..ebe4d0d3 100644 --- a/back/loaders/express.ts +++ b/back/loaders/express.ts @@ -18,7 +18,12 @@ export default ({ app }: { app: Application }) => { app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); app.use( jwt({ secret: config.secret as string, algorithms: ['HS384'] }).unless({ - path: ['/api/login', '/api/crons/status', /^\/open\//], + path: [ + '/api/login', + '/api/crons/status', + /^\/open\//, + '/api/user/two-factor/login', + ], }), ); @@ -52,7 +57,9 @@ export default ({ app }: { app: Application }) => { if ( !headerToken && req.path && - (req.path === '/api/login' || req.path === '/open/auth/token') + (req.path === '/api/login' || + req.path === '/open/auth/token' || + req.path === '/api/user/two-factor/login') ) { return next(); } diff --git a/back/services/auth.ts b/back/services/auth.ts index ea5ac154..8fc5ba30 100644 --- a/back/services/auth.ts +++ b/back/services/auth.ts @@ -5,6 +5,7 @@ import config from '../config'; import * as fs from 'fs'; import _ from 'lodash'; import jwt from 'jsonwebtoken'; +import { authenticator } from '@otplib/preset-default'; @Service() export default class AuthService { @@ -32,6 +33,8 @@ export default class AuthService { lastlogon, lastip, lastaddr, + twoFactorActived, + twoFactorChecked, } = JSON.parse(content); if ( @@ -42,7 +45,21 @@ export default class AuthService { return this.initAuthInfo(); } + if (twoFactorActived && !twoFactorChecked) { + return { + code: 420, + message: '请输入两步验证token', + }; + } + if (retries > 2 && Date.now() - lastlogon < Math.pow(3, retries) * 1000) { + fs.writeFileSync( + config.authConfigFile, + JSON.stringify({ + ...JSON.parse(content), + twoFactorChecked: false, + }), + ); return { code: 410, message: `失败次数过多,请${Math.round( @@ -57,8 +74,9 @@ export default class AuthService { const { ip, address } = await getNetIp(req); if (username === cUsername && password === cPassword) { const data = createRandomString(50, 100); + const expiration = twoFactorActived ? 30 : 3; let token = jwt.sign({ data }, config.secret as any, { - expiresIn: 60 * 60 * 24 * 3, + expiresIn: 60 * 60 * 24 * expiration, algorithm: 'HS384', }); fs.writeFileSync( @@ -70,6 +88,7 @@ export default class AuthService { retries: 0, lastip: ip, lastaddr: address, + twoFactorChecked: false, }), ); return { code: 200, data: { token, lastip, lastaddr, lastlogon } }; @@ -82,6 +101,7 @@ export default class AuthService { lastlogon: timestamp, lastip: ip, lastaddr: address, + twoFactorChecked: false, }), ); return { code: 400, message: config.authError }; @@ -105,4 +125,68 @@ export default class AuthService { message: '已初始化密码,请前往auth.json查看并重新登录', }; } + + public getUserInfo(): Promise { + return new Promise((resolve) => { + fs.readFile(config.authConfigFile, 'utf8', (err, data) => { + if (err) console.log(err); + resolve(JSON.parse(data)); + }); + }); + } + + public initTwoFactor() { + const secret = authenticator.generateSecret(); + const authInfo = this.getAuthInfo(); + const otpauth = authenticator.keyuri(authInfo.username, 'qinglong', secret); + this.updateAuthInfo(authInfo, { twoFactorSecret: secret }); + return { secret, url: otpauth }; + } + + public activeTwoFactor(code: string) { + const authInfo = this.getAuthInfo(); + const isValid = authenticator.verify({ + token: code, + secret: authInfo.twoFactorSecret, + }); + if (isValid) { + this.updateAuthInfo(authInfo, { twoFactorActived: true }); + } + return isValid; + } + + public twoFactorLogin({ username, password, code }, req) { + const authInfo = this.getAuthInfo(); + const isValid = authenticator.verify({ + token: code, + secret: authInfo.twoFactorSecret, + }); + if (isValid) { + this.updateAuthInfo(authInfo, { twoFactorChecked: true }); + return this.login({ username, password }, req); + } else { + return { code: 430, message: '验证失败' }; + } + } + + public deactiveTwoFactor() { + const authInfo = this.getAuthInfo(); + this.updateAuthInfo(authInfo, { + twoFactorActived: false, + twoFactorSecret: '', + }); + return true; + } + + private getAuthInfo() { + const content = fs.readFileSync(config.authConfigFile, 'utf8'); + return JSON.parse(content || '{}'); + } + + private updateAuthInfo(authInfo: any, info: any) { + fs.writeFileSync( + config.authConfigFile, + JSON.stringify({ ...authInfo, ...info }), + ); + } } diff --git a/package.json b/package.json index 260c7e94..393deaaf 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ ] }, "dependencies": { + "@otplib/preset-default": "^12.0.1", "body-parser": "^1.19.0", "celebrate": "^13.0.3", "cors": "^2.8.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 149d8c11..a72357ba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,7 @@ specifiers: '@ant-design/icons': ^4.6.2 '@ant-design/pro-layout': ^6.5.0 '@monaco-editor/react': ^4.2.1 + '@otplib/preset-default': ^12.0.1 '@types/cors': ^2.8.10 '@types/express': ^4.17.8 '@types/express-jwt': ^6.0.1 @@ -60,6 +61,7 @@ specifiers: yorkie: ^2.0.0 dependencies: + '@otplib/preset-default': 12.0.1 body-parser: 1.19.0 celebrate: 13.0.4 cors: 2.8.5 @@ -908,6 +910,31 @@ packages: rimraf: 3.0.2 dev: true + /@otplib/core/12.0.1: + resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} + dev: false + + /@otplib/plugin-crypto/12.0.1: + resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} + dependencies: + '@otplib/core': 12.0.1 + dev: false + + /@otplib/plugin-thirty-two/12.0.1: + resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} + dependencies: + '@otplib/core': 12.0.1 + thirty-two: 1.0.2 + dev: false + + /@otplib/preset-default/12.0.1: + resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==} + dependencies: + '@otplib/core': 12.0.1 + '@otplib/plugin-crypto': 12.0.1 + '@otplib/plugin-thirty-two': 12.0.1 + dev: false + /@qixian.cs/path-to-regexp/6.1.0: resolution: {integrity: sha512-2jIiLiVZB1jnY7IIRQKtoV8Gnr7XIhk4mC88ONGunZE3hYt5IHUG4BE/6+JiTBjjEWQLBeWnZB8hGpppkufiVw==} dev: true @@ -9084,6 +9111,11 @@ packages: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} dev: false + /thirty-two/1.0.2: + resolution: {integrity: sha1-TKL//AKlEpDSdEueP1V2k8prYno=} + engines: {node: '>=0.2.6'} + dev: false + /throat/5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} dev: true diff --git a/src/app.tsx b/src/app.tsx index 53313350..eeab9b2e 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -14,26 +14,6 @@ const titleMap: any = { '/setting': '系统设置', }; -export function render(oldRender: any) { - request - .get(`${config.apiPrefix}user`) - .then((data) => { - if (data.data && data.data.username) { - return oldRender(); - } - localStorage.removeItem(config.authKey); - history.push('/login'); - oldRender(); - }) - .catch((e) => { - if (e.response && e.response.status === 401) { - localStorage.removeItem(config.authKey); - history.push('/login'); - oldRender(); - } - }); -} - export function onRouteChange({ matchedRoutes }: any) { if (matchedRoutes.length) { const path: string = matchedRoutes[matchedRoutes.length - 1].route.path; diff --git a/src/layouts/index.tsx b/src/layouts/index.tsx index 98c018f9..7d0b0d37 100644 --- a/src/layouts/index.tsx +++ b/src/layouts/index.tsx @@ -19,6 +19,7 @@ import { useCtx, useTheme } from '@/utils/hooks'; export default function (props: any) { const ctx = useCtx(); const theme = useTheme(); + const [user, setUser] = useState(); const logout = () => { request.post(`${config.apiPrefix}logout`).then(() => { @@ -27,12 +28,29 @@ export default function (props: any) { }); }; + const getUser = () => { + request + .get(`${config.apiPrefix}user`) + .then((data) => { + if (data.data.username) { + setUser(data.data); + } + }) + .catch((e) => { + if (e.response && e.response.status === 401) { + localStorage.removeItem(config.authKey); + history.push('/login'); + } + }); + }; + useEffect(() => { const isAuth = localStorage.getItem(config.authKey); if (!isAuth) { history.push('/login'); } vhCheck(); + getUser(); // patch custome layout title as react node [object, object] document.title = '控制面板'; @@ -112,7 +130,7 @@ export default function (props: any) { {...defaultProps} > {React.Children.map(props.children, (child) => { - return React.cloneElement(child, { ...ctx, ...theme }); + return React.cloneElement(child, { ...ctx, ...theme, user }); })} ); diff --git a/src/pages/login/index.less b/src/pages/login/index.less index 7fbc78f0..7f44cac0 100644 --- a/src/pages/login/index.less +++ b/src/pages/login/index.less @@ -21,13 +21,11 @@ .content { position: absolute; - top: 45%; + top: 86px; left: 50%; - margin: -160px 0 0 -160px; - width: 320px; - height: 320px; - padding: 36px; - box-shadow: 0 0 100px rgba(0, 0, 0, 0.08); + margin-left: -170px; + width: 340px; + padding: 0 16px; } @media (min-width: @screen-md-min) { @@ -44,16 +42,20 @@ } .header { - height: 44px; - line-height: 44px; display: flex; align-items: center; - justify-content: center; + flex-direction: column; } .logo { - width: 30px; - margin-right: 8px; + width: 48px; + display: block; + margin-bottom: 24px; +} + +.title { + font-size: 20px; + margin-bottom: 16px; } .desc { @@ -64,7 +66,11 @@ } .main { - margin: 35px auto 0; + padding: 20px; + border-radius: 6px; + background-color: #f6f8fa; + border: 1px solid #ebedef; + @media screen and (max-width: @screen-sm) { width: 95%; max-width: 320px; @@ -104,3 +110,7 @@ font-size: @font-size-base; } } + +.extra { + margin-top: 20px; +} diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx index b85c879c..15db38c7 100644 --- a/src/pages/login/index.tsx +++ b/src/pages/login/index.tsx @@ -13,6 +13,7 @@ import { history, Link } from 'umi'; import styles from './index.less'; import { request } from '@/utils/http'; import { useTheme } from '@/utils/hooks'; +import { MobileOutlined } from '@ant-design/icons'; const FormItem = Form.Item; const { Countdown } = Statistic; @@ -21,9 +22,13 @@ const Login = () => { const [loading, setLoading] = useState(false); const [waitTime, setWaitTime] = useState(); const { theme } = useTheme(); + const [twoFactor, setTwoFactor] = useState(false); + const [loginInfo, setLoginInfo] = useState(); + const [verifing, setVerifing] = useState(false); const handleOk = (values: any) => { setLoading(true); + setTwoFactor(false); setWaitTime(null); request .post(`${config.apiPrefix}login`, { @@ -33,31 +38,14 @@ const Login = () => { }, }) .then((data) => { - if (data.code === 200) { - const { token, lastip, lastaddr, lastlogon } = data.data; - localStorage.setItem(config.authKey, token); - notification.success({ - message: '登录成功!', - description: ( - <> -
- 上次登录时间: - {lastlogon ? new Date(lastlogon).toLocaleString() : '-'} -
-
上次登录地点:{lastaddr || '-'}
-
上次登录IP:{lastip || '-'}
- - ), - duration: 5, + if (data.code === 420) { + setLoginInfo({ + username: values.username, + password: values.password, }); - history.push('/crontab'); - } else if (data.code === 100) { - message.warn(data.message); - } else if (data.code === 410) { - message.error(data.message); - setWaitTime(data.data); + setTwoFactor(true); } else { - message.error(data.message); + checkResponse(data); } setLoading(false); }) @@ -67,6 +55,55 @@ const Login = () => { }); }; + const completeTowFactor = (values: any) => { + setVerifing(true); + request + .put(`${config.apiPrefix}user/two-factor/login`, { + data: { ...loginInfo, code: values.code }, + }) + .then((data: any) => { + if (data.code === 430) { + message.error(data.message); + } else { + checkResponse(data); + } + setVerifing(false); + }) + .catch((error: any) => { + console.log(error); + setVerifing(false); + }); + }; + + const checkResponse = (data: any) => { + if (data.code === 200) { + const { token, lastip, lastaddr, lastlogon } = data.data; + localStorage.setItem(config.authKey, token); + notification.success({ + message: '登录成功!', + description: ( + <> +
+ 上次登录时间: + {lastlogon ? new Date(lastlogon).toLocaleString() : '-'} +
+
上次登录地点:{lastaddr || '-'}
+
上次登录IP:{lastip || '-'}
+ + ), + duration: 5, + }); + history.push('/crontab'); + } else if (data.code === 100) { + message.warn(data.message); + } else if (data.code === 410) { + message.error(data.message); + setWaitTime(data.data); + } else { + message.error(data.message); + } + }; + useEffect(() => { const isAuth = localStorage.getItem(config.authKey); if (isAuth) { @@ -84,55 +121,88 @@ const Login = () => { className={styles.logo} src="/images/qinglong.png" /> - {config.siteName} + + {twoFactor ? '两步验证' : config.siteName} +
-
- - - - - - - - {waitTime ? ( - - ) : ( - - )} - -
+ {twoFactor ? ( +
+ + + + +
+ ) : ( +
+ + + + + + + + {waitTime ? ( + + ) : ( + + )} + +
+ )} +
+
+ {twoFactor ? ( +
+ + 在您的设备上打开两步验证应用程序以查看您的身份验证代码并验证您的身份。 +
+ ) : ( + '' + )}
diff --git a/src/pages/setting/index.tsx b/src/pages/setting/index.tsx index b805dd6c..ef35df99 100644 --- a/src/pages/setting/index.tsx +++ b/src/pages/setting/index.tsx @@ -22,13 +22,13 @@ import { auto as followSystemColorScheme, setFetchMethod, } from 'darkreader'; -import { history } from 'umi'; import AppModal from './appModal'; import { EditOutlined, DeleteOutlined, ReloadOutlined, } from '@ant-design/icons'; +import SecuritySettings from './security'; const { Text } = Typography; const optionsWithDisabled = [ @@ -37,7 +37,7 @@ const optionsWithDisabled = [ { label: '跟随系统', value: 'auto' }, ]; -const Setting = ({ headerStyle, isPhone }: any) => { +const Setting = ({ headerStyle, isPhone, user }: any) => { const columns = [ { title: '名称', @@ -109,24 +109,7 @@ const Setting = ({ headerStyle, isPhone }: any) => { const [dataSource, setDataSource] = useState([]); const [isModalVisible, setIsModalVisible] = useState(false); const [editedApp, setEditedApp] = useState(); - const [tabActiveKey, setTabActiveKey] = useState('person'); - - const handleOk = (values: any) => { - request - .post(`${config.apiPrefix}user`, { - data: { - username: values.username, - password: values.password, - }, - }) - .then((data: any) => { - localStorage.removeItem(config.authKey); - history.push('/login'); - }) - .catch((error: any) => { - console.log(error); - }); - }; + const [tabActiveKey, setTabActiveKey] = useState('security'); const themeChange = (e: any) => { setTheme(e.target.value); @@ -272,35 +255,13 @@ const Setting = ({ headerStyle, isPhone }: any) => { } > - -
- - - - - - - -
+ + { + const [loading, setLoading] = useState(false); + const [twoFactorActived, setTwoFactorActived] = useState(); + const [twoFactoring, setTwoFactoring] = useState(false); + const [twoFactorInfo, setTwoFactorInfo] = useState(); + const [code, setCode] = useState(); + + const handleOk = (values: any) => { + request + .post(`${config.apiPrefix}user`, { + data: { + username: values.username, + password: values.password, + }, + }) + .then((data: any) => { + localStorage.removeItem(config.authKey); + history.push('/login'); + }) + .catch((error: any) => { + console.log(error); + }); + }; + + const activeOrDeactiveTwoFactor = () => { + if (twoFactorActived) { + deactiveTowFactor(); + } else { + getTwoFactorInfo(); + setTwoFactoring(true); + } + }; + + const deactiveTowFactor = () => { + request + .put(`${config.apiPrefix}user/two-factor/deactive`) + .then((data: any) => { + if (data.data) { + setTwoFactorActived(false); + } + }) + .catch((error: any) => { + console.log(error); + }); + }; + + const completeTowFactor = () => { + request + .put(`${config.apiPrefix}user/two-factor/active`, { data: { code } }) + .then((data: any) => { + if (data.data) { + message.success('激活成功'); + setTwoFactoring(false); + setTwoFactorActived(true); + } + }) + .catch((error: any) => { + console.log(error); + }); + }; + + const getTwoFactorInfo = () => { + request + .get(`${config.apiPrefix}user/two-factor/init`) + .then((data: any) => { + setTwoFactorInfo(data.data); + }) + .catch((error: any) => { + console.log(error); + }); + }; + + useEffect(() => { + setTwoFactorActived(user && user.twoFactorActived); + }, [user]); + + return twoFactoring ? ( + <> + {twoFactorInfo ? ( +
+ 第一步 + 下载两步验证手机应用,比如 Google Authenticator 、 + + Microsoft Authenticator + + 、 + + Authy + + 、 + + 1Password + + 、 + + LastPass Authenticator + + + 第二步 + + 使用手机应用扫描二维码,或者输入秘钥 {twoFactorInfo?.secret} +
+ +
+ + 第三步 + + 输入手机应用上的6位数字 + setCode(e.target.value)} + placeholder="123456" + /> + +
+ ) : ( + + )} + + ) : ( + <> +
+ 修改用户名密码 +
+
+ + + + + + + + + +
+ 两步验证 +
+ + + ); +}; + +export default SecuritySettings;