diff --git a/package.json b/package.json index 841f70653..342dc23e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "teledrive", - "version": "1.3.1", + "version": "1.4.0", "repository": "git@github.com:mgilangjanuar/teledrive.git", "author": "M Gilang Januar ", "license": "MIT", diff --git a/server/package.json b/server/package.json index fb113aa4e..3e4cfb61a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "1.3.1", + "version": "1.4.0", "main": "dist/index.js", "license": "MIT", "private": true, @@ -9,7 +9,7 @@ "build": "rimraf dist && eslint --fix -c .eslintrc.js --ext .ts . && tsc" }, "dependencies": { - "@mgilangjanuar/telegram": "2.2.1", + "@mgilangjanuar/telegram": "2.2.3-var1", "@sentry/node": "^6.14.1", "@sentry/tracing": "^6.14.1", "@types/moment": "^2.13.0", diff --git a/server/src/api/v1/Auth.ts b/server/src/api/v1/Auth.ts index 2bfca78b4..af4c82589 100644 --- a/server/src/api/v1/Auth.ts +++ b/server/src/api/v1/Auth.ts @@ -8,7 +8,7 @@ import { sign, verify } from 'jsonwebtoken' import { getRepository } from 'typeorm' import { Users } from '../../model//entities/Users' import { Files } from '../../model/entities/Files' -import { COOKIE_AGE, TG_CREDS } from '../../utils/Constant' +import { CONNECTION_RETRIES, COOKIE_AGE, TG_CREDS } from '../../utils/Constant' import { Endpoint } from '../base/Endpoint' import { TGClient } from '../middlewares/TGClient' import { TGSessionAuth } from '../middlewares/TGSessionAuth' @@ -153,7 +153,7 @@ export class Auth { } /** - * Experimental + * Initialize export login token to be a param for URL tg://login?token={{token}} * @param req * @param res * @returns @@ -165,26 +165,161 @@ export class Auth { ...TG_CREDS, exceptIds: [] })) - return res.cookie('authorization', `Bearer ${req.tg.session.save()}`).send({ token: Buffer.from(data['token']).toString('base64') }) + + const session = req.tg.session.save() + const auth = { + accessToken: sign({ session }, process.env.API_JWT_SECRET, { expiresIn: '15h' }), + refreshToken: sign({ session }, process.env.API_JWT_SECRET, { expiresIn: '100y' }), + expiredAfter: Date.now() + COOKIE_AGE + } + return res + .cookie('authorization', `Bearer ${auth.accessToken}`, { maxAge: COOKIE_AGE, expires: new Date(auth.expiredAfter) }) + .cookie('refreshToken', auth.refreshToken, { maxAge: 3.154e+10, expires: new Date(Date.now() + 3.154e+10) }) + .send({ loginToken: Buffer.from(data['token'], 'utf8').toString('base64url'), accessToken: auth.accessToken }) } /** - * Experimental + * Sign in process with QR Code https://core.telegram.org/api/qr-login * @param req * @param res * @returns */ @Endpoint.POST({ middlewares: [TGSessionAuth] }) public async qrCodeSignIn(req: Request, res: Response): Promise { - const { token } = req.body - if (!token) { - throw { status: 400, body: { error: 'Token is required' } } + const { password, session: sessionString } = req.body + + // handle the 2fa password in the second call + if (password && sessionString) { + req.tg = new TelegramClient(new StringSession(sessionString), TG_CREDS.apiId, TG_CREDS.apiHash, { connectionRetries: CONNECTION_RETRIES, useWSS: false }) + await req.tg.connect() + + const passwordData = await req.tg.invoke(new Api.account.GetPassword()) + + passwordData.newAlgo['salt1'] = Buffer.concat([passwordData.newAlgo['salt1'], generateRandomBytes(32)]) + const signIn = await req.tg.invoke(new Api.auth.CheckPassword({ + password: await computeCheck(passwordData, password) + })) + const userAuth = signIn['user'] + if (!userAuth) { + throw { status: 400, body: { error: 'User not found/authorized' } } + } + + let user = await Users.findOne({ tg_id: userAuth.id.toString() }) + if (!user) { + const username = userAuth.username || userAuth.phone + user = await getRepository(Users).save({ + username, + name: `${userAuth.firstName || ''} ${userAuth.lastName || ''}`.trim() || username, + tg_id: userAuth.id.toString() + }, { reload: true }) + } + + const session = req.tg.session.save() + const auth = { + accessToken: sign({ session }, process.env.API_JWT_SECRET, { expiresIn: '15h' }), + refreshToken: sign({ session }, process.env.API_JWT_SECRET, { expiresIn: '1y' }), + expiredAfter: Date.now() + COOKIE_AGE + } + + res + .cookie('authorization', `Bearer ${auth.accessToken}`, { maxAge: COOKIE_AGE, expires: new Date(auth.expiredAfter) }) + .cookie('refreshToken', auth.refreshToken, { maxAge: 3.154e+10, expires: new Date(Date.now() + 3.154e+10) }) + .send({ user, ...auth }) + + // sync all shared files in background, if any + Files.createQueryBuilder('files') + .where('user_id = :user_id and signed_key is not null', { user_id: user.id }) + .getMany() + .then(files => files?.map(file => { + const signedKey = AES.encrypt(JSON.stringify({ file: { id: file.id }, session: req.tg.session.save() }), process.env.FILES_JWT_SECRET).toString() + Files.update(file.id, { signed_key: signedKey }) + })) + return } + + // handle the second call for export login token, result case: success, need to migrate to other dc, or 2fa await req.tg.connect() - const data = await req.tg.invoke(new Api.auth.AcceptLoginToken({ - token: Buffer.from(token, 'base64') - })) - return res.cookie('authorization', `Bearer ${req.tg.session.save()}`).send({ data }) + try { + const data = await req.tg.invoke(new Api.auth.ExportLoginToken({ + ...TG_CREDS, + exceptIds: [] + })) + + // build response with user data and auth data + const buildResponse = (data: Record & { user?: { id: string } })=> { + const session = req.tg.session.save() + const auth = { + accessToken: sign({ session }, process.env.API_JWT_SECRET, { expiresIn: '15h' }), + refreshToken: sign({ session }, process.env.API_JWT_SECRET, { expiresIn: '1y' }), + expiredAfter: Date.now() + COOKIE_AGE + } + res + .cookie('authorization', `Bearer ${auth.accessToken}`, { maxAge: COOKIE_AGE, expires: new Date(auth.expiredAfter) }) + .cookie('refreshToken', auth.refreshToken, { maxAge: 3.154e+10, expires: new Date(Date.now() + 3.154e+10) }) + .send({ ...data, ...auth }) + + if (data.user?.id) { + // sync all shared files in background, if any + Files.createQueryBuilder('files') + .where('user_id = :user_id and signed_key is not null', { user_id: data.user.id }) + .getMany() + .then(files => files?.map(file => { + const signedKey = AES.encrypt(JSON.stringify({ file: { id: file.id }, session: req.tg.session.save() }), process.env.FILES_JWT_SECRET).toString() + Files.update(file.id, { signed_key: signedKey }) + })) + } + return + } + + // handle to switch dc + if (data instanceof Api.auth.LoginTokenMigrateTo) { + await req.tg._switchDC(data.dcId) + const result = await req.tg.invoke(new Api.auth.ImportLoginToken({ + token: data.token + })) + + // result import login token success + if (result instanceof Api.auth.LoginTokenSuccess && result.authorization instanceof Api.auth.Authorization) { + const userAuth = result.authorization.user + let user = await Users.findOne({ tg_id: userAuth.id.toString() }) + if (!user) { + const username = userAuth['username'] || userAuth['phone'] + user = await getRepository(Users).save({ + username, + name: `${userAuth['firstName'] || ''} ${userAuth['lastName'] || ''}`.trim() || username, + tg_id: userAuth.id.toString() + }, { reload: true }) + } + return buildResponse({ user }) + } + return buildResponse({ data, result }) + + // handle if success + } else if (data instanceof Api.auth.LoginTokenSuccess && data.authorization instanceof Api.auth.Authorization) { + const userAuth = data.authorization.user + let user = await Users.findOne({ tg_id: userAuth.id.toString() }) + if (!user) { + const username = userAuth['username'] || userAuth['phone'] + user = await getRepository(Users).save({ + username, + name: `${userAuth['firstName'] || ''} ${userAuth['lastName'] || ''}`.trim() || username, + tg_id: userAuth.id.toString() + }, { reload: true }) + } + return buildResponse({ user }) + } + + // data instanceof auth.LoginToken + return buildResponse({ + loginToken: Buffer.from(data['token'], 'utf8').toString('base64url') + }) + } catch (error) { + // handle if need 2fa password + if (error.errorMessage === 'SESSION_PASSWORD_NEEDED') { + error.session = req.tg.session.save() + } + throw error + } } @Endpoint.GET({ middlewares: [TGSessionAuth] }) diff --git a/web/package.json b/web/package.json index 8094c2693..c270d1e0d 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "1.3.1", + "version": "1.4.0", "private": true, "dependencies": { "@craco/craco": "^6.3.0", @@ -25,7 +25,7 @@ "react-markdown": "^7.0.1", "react-otp-input": "^2.4.0", "react-paypal-button-v2": "^2.6.3", - "react-qr-code": "^2.0.2", + "react-qr-code": "^2.0.3", "react-router-dom": "^5.3.0", "react-scripts": "^4.0.3", "react-twitter-widgets": "^1.10.0", diff --git a/web/public/app.dark.css b/web/public/app.dark.css index 79e54a751..c77ba4c9d 100644 --- a/web/public/app.dark.css +++ b/web/public/app.dark.css @@ -32,7 +32,9 @@ color: rgba(255, 255, 255, 0.45) !important; } -.ant-modal-body .ant-form-item-label { +.ant-modal-body .ant-form-item-label, +.ant-modal-body .ant-form-item-control-input, +.ant-modal-body .ant-row { background: #1f1f1f !important; } diff --git a/web/src/index.tsx b/web/src/index.tsx index 41b7d0d38..c2471a686 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -20,9 +20,7 @@ ReactDOM.render( // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://cra.link/PWA -serviceWorkerRegistration.register({ - onUpdate: () => (window.location as any).reload(true) -}) +serviceWorkerRegistration.register() // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx index 595b41749..c39168bd9 100644 --- a/web/src/pages/Login.tsx +++ b/web/src/pages/Login.tsx @@ -1,10 +1,11 @@ import { ArrowRightOutlined, CheckCircleTwoTone, LoginOutlined } from '@ant-design/icons' -import { Button, Card, Col, Collapse, Form, Input, Layout, notification, Row, Steps, Typography } from 'antd' +import { Button, Card, Col, Collapse, Form, Input, Layout, notification, Row, Spin, Steps, Typography } from 'antd' import CountryPhoneInput, { ConfigProvider } from 'antd-country-phone-input' import { useForm } from 'antd/lib/form/Form' import JSCookie from 'js-cookie' import React, { useEffect, useState } from 'react' import OtpInput from 'react-otp-input' +import QRCode from 'react-qr-code' import { useHistory } from 'react-router' import useSWRImmutable from 'swr/immutable' import en from 'world_countries_lists/data/en/world.json' @@ -17,6 +18,7 @@ interface Props { const Login: React.FC = ({ me }) => { const history = useHistory() const [formLogin] = useForm() + const [formLoginQRCode] = useForm() const [dc, setDc] = useState() const [currentStep, setCurrentStep] = useState(0) const [phoneData, setPhoneData] = useState<{ phone?: string, code?: number, short?: string }>({}) @@ -26,7 +28,9 @@ const Login: React.FC = ({ me }) => { const [countdown, setCountdown] = useState() const [phoneCodeHash, setPhoneCodeHash] = useState() const [needPassword, setNeedPassword] = useState() + const [method, setMethod] = useState<'phoneNumber' | 'qrCode'>('phoneNumber') const { data: _ } = useSWRImmutable('/utils/ipinfo', fetcher, { onSuccess: ({ ipinfo }) => setPhoneData(phoneData?.short ? phoneData : { short: ipinfo?.country || 'ID' }) }) + const [qrCode, setQrCode] = useState<{ loginToken: string, accessToken: string, session?: string }>() useEffect(() => { if (window.location.host === 'ge.teledriveapp.com') { @@ -90,13 +94,9 @@ const Login: React.FC = ({ me }) => { setLoadingLogin(false) notification.success({ message: 'Success', - description: `Welcome back, ${data.user.username}!` - }) - history.replace('/dashboard') - return notification.info({ - message: 'Info', - description: 'Please wait a moment...' + description: `Welcome back, ${data.user.name || data.user.username}! Please wait a moment...` }) + return history.replace('/dashboard') } catch (error: any) { setLoadingLogin(false) const { data } = error?.response @@ -115,6 +115,26 @@ const Login: React.FC = ({ me }) => { } } + const loginByQrCode = async () => { + try { + const { password } = formLoginQRCode.getFieldsValue() + setLoadingLogin(true) + const { data } = await req.post('/auth/qrCodeSignIn', { password, session: qrCode?.session }) + notification.success({ + message: 'Success', + description: `Welcome back, ${data.user.name || data.user.username}! Please wait a moment...` + }) + setLoadingLogin(false) + return history.replace('/dashboard') + } catch (error: any) { + setLoadingLogin(false) + return notification.error({ + message: 'Error', + description: error.response?.data?.error || 'Something error' + }) + } + } + useEffect(() => { if (JSCookie.get('authorization') && me?.user) { console.log(me.user) @@ -130,10 +150,58 @@ const Login: React.FC = ({ me }) => { } }, [countdown]) + useEffect(() => { + if (method === 'qrCode') { + if (!qrCode?.loginToken) { + req.get('/auth/qrCode').then(({ data }) => { + setQrCode(data) + }) + } + } else { + setQrCode(undefined) + } + }, [method]) + + useEffect(() => { + if (qrCode && method === 'qrCode' && !needPassword) { + setTimeout(() => { + if (method === 'qrCode' && !needPassword && qrCode?.loginToken && qrCode?.accessToken) { + req.post('/auth/qrCodeSignIn', {}, { headers: { + 'Authorization': `Bearer ${qrCode.accessToken}` + } }).then(({ data }) => { + if (data?.user) { + notification.success({ + message: 'Success', + description: `Welcome back, ${data.user.name || data.user.username}! Please wait a moment...` + }) + history.replace('/dashboard') + } else { + setQrCode(data) + } + }).catch(({ response }: any) => { + if (response?.data?.details?.errorMessage === 'SESSION_PASSWORD_NEEDED') { + notification.info({ + message: 'Info', + description: 'Please input your 2FA password' + }) + setQrCode({ ...qrCode, session: response.data.details.session }) + setNeedPassword(true) + } else { + notification.error({ + message: 'Error', + description: response?.data?.error || 'Something error' + }) + } + }) + } + }, 3000) + } + }, [qrCode, method]) + return <> - + @@ -203,66 +271,100 @@ const Login: React.FC = ({ me }) => { Please download Telegram app and login with your account. - - - - {needPassword && } - -
- - {currentStep === 0 && <> - - - setPhoneData(e)} /> - - {/* */} - - - - - } + {method === 'phoneNumber' && <> + + + + {needPassword && } + + - {currentStep === 1 && <> - - - Authentication code sent to +{phoneData.code}•••••••{phoneData.phone?.substring(phoneData.phone.length - 4)} - - - {countdown ? Re-send in {countdown}s... : - - } - - - - - - - + {currentStep === 0 && <> + + + setPhoneData(e)} /> + + {/* */} + + + + + + - - } - - {currentStep === 2 && <> - - - - - } + } + {currentStep === 1 && <> + + + Authentication code sent to +{phoneData.code}•••••••{phoneData.phone?.substring(phoneData.phone.length - 4)} + + + {countdown ? Re-send in {countdown}s... : + + } + + + + + + + + + + } -
+ {currentStep === 2 && <> + + + + + } + + } + {method === 'qrCode' && <> + + + {!needPassword ? <> + + {qrCode?.loginToken ? : } + + + Log in to Telegram by QR Code + +
    +
  1. Open Telegram on your phone
  2. +
  3. Go to Settings > Devices > Link Desktop Device
  4. +
  5. Point your phone at this screen to confirm login
  6. +
+ + + + : <> +
+ + + + +
+ } +
+
+ }
diff --git a/yarn.lock b/yarn.lock index 1fba75421..3ba64ad66 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1549,10 +1549,10 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" -"@mgilangjanuar/telegram@2.2.1": - version "2.2.1" - resolved "https://npm.pkg.github.com/download/@mgilangjanuar/telegram/2.2.1/fc45763251ef5d04d8433d20579c74a086458e83d4cfed8259ea3d127a3999b7#c8365bc67fcfde05f08d4136bd9e9c67ab0df8e6" - integrity sha512-24JkwvwNrq/NlXa5bdqk1RdNrI6XVla1xanxvvW/N17S+no90eoiXpgEXXvNuvgt2KHf3zOPODbFc83m0fKLkw== +"@mgilangjanuar/telegram@2.2.3-var1": + version "2.2.3-var1" + resolved "https://npm.pkg.github.com/download/@mgilangjanuar/telegram/2.2.3-var1/1ddfdfae1fa3e107abfb493231c2c8354714215d7ec646fbde535cf184088b96#414b71751e911b587681c40a987a93ee5fedce48" + integrity sha512-P5eopPpvTQ+ZAUzvjfcgVgXjz6LZw8TsrdSUBeLksx25FqwYxRTQnfCOkfBHN04jNjsAgc7LLb/IxHeBxs3g8w== dependencies: "@cryptography/aes" "^0.1.1" async-mutex "^0.3.0" @@ -12566,10 +12566,10 @@ react-progress-bar.js@^0.2.3: lodash.isequal "^4.1.4" progressbar.js "^1.0.1" -react-qr-code@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.2.tgz#64107c869079aceb897c97496d163720ab2820e8" - integrity sha512-73VGe81MgeE5FJNFgdY42ez/wPPJTHuooU3iE4CX+6F8M88O1Gg4zNA0L4bKEpoySQ0QjqreJgyXjFrG/QfsdA== +react-qr-code@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/react-qr-code/-/react-qr-code-2.0.3.tgz#cc80785e08f817d1ab066ca4035262f77d049648" + integrity sha512-6GDH0l53lksf2JgZwwcoS0D60a1OAal/GQRyNFkMBW19HjSqvtD5S20scmSQsKl+BgWM85Wd5DCcUYoHd+PZnQ== dependencies: prop-types "^15.7.2" qr.js "0.0.0"