Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(condo): DOMA-11012 telegram auth #5783

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion apps/condo/domains/user/constants/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const USER_TYPES = [STAFF, RESIDENT, SERVICE]
const APPLE_ID_IDP_TYPE = 'apple_id'
const SBER_ID_IDP_TYPE = 'sber_id'
const SBBOL_IDP_TYPE = 'sbbol'
const IDP_TYPES = [APPLE_ID_IDP_TYPE, SBER_ID_IDP_TYPE, SBBOL_IDP_TYPE]
const TELEGRAM_IDP_TYPE = 'telegram_auth'
const IDP_TYPES = [TELEGRAM_IDP_TYPE, APPLE_ID_IDP_TYPE, SBER_ID_IDP_TYPE, SBBOL_IDP_TYPE]

const MIN_PASSWORD_LENGTH = 8
const MAX_PASSWORD_LENGTH = 128
Expand Down Expand Up @@ -59,6 +60,7 @@ module.exports = {
APPLE_ID_IDP_TYPE,
SBER_ID_IDP_TYPE,
SBBOL_IDP_TYPE,
TELEGRAM_IDP_TYPE,
IDP_TYPES,
SBER_ID_SESSION_KEY,
APPLE_ID_SESSION_KEY,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
const bodyParser = require('body-parser')
const express = require('express')

const { expressErrorHandler } = require('@open-condo/keystone/logging/expressErrorHandler')

const { SbbolRoutes } = require('@condo/domains/organization/integrations/sbbol/routes')
const { AppleIdRoutes } = require('@condo/domains/user/integration/appleid/routes')
const { SberIdRoutes } = require('@condo/domains/user/integration/sberid/routes')
const { TelegramAuthRoutes } = require('@condo/domains/user/integration/telegram/routes')

class UserExternalIdentityMiddleware {
async prepareMiddleware ({ keystone }) {
Expand All @@ -30,6 +30,10 @@ class UserExternalIdentityMiddleware {
app.get('/api/sber_id/auth', sberIdRoutes.startAuth.bind(sberIdRoutes))
app.get('/api/sber_id/auth/callback', sberIdRoutes.completeAuth.bind(sberIdRoutes))

const telegramAuthRoutes = new TelegramAuthRoutes()
app.get('/api/telegram/auth', telegramAuthRoutes.startAuth.bind(telegramAuthRoutes))
app.post('/api/telegram/auth/status', telegramAuthRoutes.getAuthStatus.bind(telegramAuthRoutes))

// error handler
app.use(expressErrorHandler)

Expand Down
115 changes: 115 additions & 0 deletions apps/condo/domains/user/integration/telegram/BotController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
const { get } = require('lodash')
const TelegramBot = require('node-telegram-bot-api')

const conf = require('@open-condo/config')
const { getRedisClient } = require('@open-condo/keystone/redis')
const { getSchemaCtx } = require('@open-condo/keystone/schema')
const { i18n } = require('@open-condo/locales/loader')

const {
TELEGRAM_AUTH_REDIS_START,
TELEGRAM_AUTH_REDIS_PENDING,
TELEGRAM_AUTH_REDIS_TOKEN,
} = require('@condo/domains/user/integration/telegram/constants')
const { TELEGRAM_AUTH_STATUS_PENDING } = require('@condo/domains/user/integration/telegram/constants')
const { syncUser } = require('@condo/domains/user/integration/telegram/sync/syncUser')
const { startAuthedSession } = require('@condo/domains/user/integration/telegram/utils')


const TELEGRAM_AUTH_BOT_TOKEN = conf.TELEGRAM_AUTH_BOT_TOKEN
const redisClient = getRedisClient()

class TelegramAuthBotController {
#bot = null
constructor () {
if (TELEGRAM_AUTH_BOT_TOKEN) {
this.#bot = new TelegramBot(TELEGRAM_AUTH_BOT_TOKEN, { polling: true })
}
}

init () {
if (this.#bot) {
this.#bot.onText(/\/start (.+)/, this.#handleStart.bind(this))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth processing a simple /start command?

this.#bot.on('contact', this.#handleContact.bind(this))
}
}

async #handleStart (message, match) {
const chatId = get(message, 'chat.id')
const locale = get(message, 'from.language_code', conf.DEFAULT_LOCALE)
const startKey = match[1]

if (!chatId || !startKey) return

const startData = await redisClient.get(`${TELEGRAM_AUTH_REDIS_START}${startKey}`)
await redisClient.del(`${TELEGRAM_AUTH_REDIS_START}${startKey}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that forming a query in Redis based on user input without additional checks is not the best idea


if (!startData) {
return this.#sendMessage(chatId, 'telegram.auth.error', locale)
}

await redisClient.set(`${TELEGRAM_AUTH_REDIS_PENDING}${chatId}`, startData, 'EX', 300)

this.#sendMessage(chatId, 'telegram.auth.start.response', locale, {
reply_markup: {
keyboard: [[{ text: i18n('telegram.auth.start.shareButton', { locale }), request_contact: true }]],
one_time_keyboard: true,
resize_keyboard: true,
},
})
}

async #handleContact (message) {
const chatId = get(message, 'chat.id')
const fromId = get(message, 'from.id')
const contact = get(message, 'contact')
const locale = get(message, 'from.language_code', conf.DEFAULT_LOCALE)

if (!chatId || !contact) return

const { phone_number: phoneNumber, first_name: firstName, user_id: userId } = contact
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about lastname?


if (fromId !== userId) {
return this.#sendMessage(chatId, 'telegram.auth.contact.wrongContact', locale)
}

const startData = await redisClient.get(`${TELEGRAM_AUTH_REDIS_PENDING}${chatId}`)
await redisClient.del(`${TELEGRAM_AUTH_REDIS_PENDING}${chatId}`)

if (!startData) {
return this.#sendMessage(chatId, 'telegram.auth.error', locale)
}

await this.#authenticateUser(chatId, startData, { phoneNumber, firstName, userId }, locale)
}

async #authenticateUser (chatId, startData, userInfo, locale) {
let uniqueKey, userType
try {
({ uniqueKey, userType } = JSON.parse(startData))
await redisClient.del(`${TELEGRAM_AUTH_REDIS_START}${chatId}`)
const tokenPending = await redisClient.get(`${TELEGRAM_AUTH_REDIS_TOKEN}${uniqueKey}`)

if (tokenPending !== TELEGRAM_AUTH_STATUS_PENDING) {
return this.#sendMessage(chatId, 'telegram.auth.error', locale)
}

const { keystone: context } = getSchemaCtx('User')
const { id } = await syncUser({ context, userInfo, userType })
const token = await startAuthedSession(id, context._sessionManager._sessionStore)

await redisClient.set(`${TELEGRAM_AUTH_REDIS_TOKEN}${uniqueKey}`, token, 'EX', 300)

this.#sendMessage(chatId, 'telegram.auth.contact.complete', locale)
} catch (error) {
this.#sendMessage(chatId, 'telegram.auth.error', locale)
}
}

#sendMessage (chatId, translationKey, locale, options = {}) {
const messageText = i18n(translationKey, { locale })
this.#bot.sendMessage(chatId, messageText, options)
}
}

module.exports = TelegramAuthBotController
16 changes: 16 additions & 0 deletions apps/condo/domains/user/integration/telegram/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const TELEGRAM_AUTH_STATUS_PENDING = 'pending'
const TELEGRAM_AUTH_STATUS_SUCCESS = 'success'
const TELEGRAM_AUTH_STATUS_ERROR = 'error'

const TELEGRAM_AUTH_REDIS_START = 'telegram:start:'
const TELEGRAM_AUTH_REDIS_TOKEN = 'telegram:token:'
const TELEGRAM_AUTH_REDIS_PENDING = 'telegram:pending:'

module.exports = {
TELEGRAM_AUTH_STATUS_PENDING,
TELEGRAM_AUTH_STATUS_SUCCESS,
TELEGRAM_AUTH_STATUS_ERROR,
TELEGRAM_AUTH_REDIS_START,
TELEGRAM_AUTH_REDIS_TOKEN,
TELEGRAM_AUTH_REDIS_PENDING,
}
75 changes: 75 additions & 0 deletions apps/condo/domains/user/integration/telegram/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const crypto = require('crypto')

const { v4: uuid } = require('uuid')

const { getLogger } = require('@open-condo/keystone/logging')
const { getRedisClient } = require('@open-condo/keystone/redis')

const TelegramAuthBotController = require('@condo/domains/user/integration/telegram/BotController')
const {
TELEGRAM_AUTH_STATUS_PENDING,
TELEGRAM_AUTH_STATUS_ERROR,
TELEGRAM_AUTH_STATUS_SUCCESS,
TELEGRAM_AUTH_REDIS_START,
TELEGRAM_AUTH_REDIS_TOKEN,
} = require('@condo/domains/user/integration/telegram/constants')
const { getUserType } = require('@condo/domains/user/integration/telegram/utils')

const TELEGRAM_AUTH_BOT_URL = process.env.TELEGRAM_AUTH_BOT_URL

const logger = getLogger('telegram-auth')
const redisClient = getRedisClient()

class TelegramAuthRoutes {
#telegramAuthBot = new TelegramAuthBotController()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you going to run the bot in every pod for condo-app? Will this work well?
I thought it was supposed to be a separate pod for a bot


constructor () {
this.#telegramAuthBot.init()
}

async startAuth (req, res, next) {
try {
if (!TELEGRAM_AUTH_BOT_URL) throw new Error('TELEGRAM_AUTH_BOT_URL is not configured')
const userType = getUserType(req)
const startKey = uuid()
const uniqueKey = crypto.randomBytes(32).toString('hex')
const startLink = `${TELEGRAM_AUTH_BOT_URL}?start=${startKey}`

await Promise.all([
redisClient.set(`${TELEGRAM_AUTH_REDIS_START}${startKey}`, JSON.stringify({ uniqueKey, userType }), 'EX', 300),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe make a small utility for generating a key, so as not to generate it manually each time?

redisClient.set(`${TELEGRAM_AUTH_REDIS_TOKEN}${uniqueKey}`, TELEGRAM_AUTH_STATUS_PENDING, 'EX', 300),
])

res.json({ startLink, uniqueKey })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
res.json({ startLink, uniqueKey })
return res.json({ startLink, uniqueKey })

} catch (error) {
logger.error({ msg: 'Telegram auth start error', reqId: req.id, error })
next(error)
}
}

async getAuthStatus (req, res, next) {
try {
const { uniqueKey } = req.body
if (!uniqueKey) {
return res.status(400).json({ status: 'error', message: 'Missing uniqueKey' })
}

const token = await redisClient.get(`${TELEGRAM_AUTH_REDIS_TOKEN}${uniqueKey}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is auth status, not token

if (token === null) {
return res.status(400).json({ status: TELEGRAM_AUTH_STATUS_ERROR, message: 'uniqueKey is expired' })
}

if (token === TELEGRAM_AUTH_STATUS_PENDING) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are you comparing the token with auth status?

return res.json({ status: TELEGRAM_AUTH_STATUS_PENDING })
}

await redisClient.del(`${TELEGRAM_AUTH_REDIS_TOKEN}${uniqueKey}`)
res.json({ token, status: TELEGRAM_AUTH_STATUS_SUCCESS })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
res.json({ token, status: TELEGRAM_AUTH_STATUS_SUCCESS })
return res.json({ token, status: TELEGRAM_AUTH_STATUS_SUCCESS })

} catch (error) {
logger.error({ msg: 'Telegram auth status error', reqId: req.id, error })
next(error)
}
}
}

module.exports = { TelegramAuthRoutes }
71 changes: 71 additions & 0 deletions apps/condo/domains/user/integration/telegram/sync/syncUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const { v4: uuid } = require('uuid')

const { normalizePhone } = require('@condo/domains/common/utils/phone')
const { TELEGRAM_IDP_TYPE } = require('@condo/domains/user/constants/common')
const {
User,
UserExternalIdentity,
} = require('@condo/domains/user/utils/serverSchema')

const dv = 1
const sender = { dv, fingerprint: 'user-external-identity-router' }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's worth adding information to the fingerprint that this is an integration with Telegram?


const linkUser = async (context, user, userInfo) => {
await UserExternalIdentity.create(context, {
dv,
sender,
user: { connect: { id: user.id } },
identityId: String(userInfo.userId),
identityType: TELEGRAM_IDP_TYPE,
meta: userInfo,
})

return user
}

const registerUser = async (context, userInfo, userType) => {
const normalizedPhone = normalizePhone(userInfo.phoneNumber)
const password = uuid()

const userData = {
password,
phone: normalizedPhone,
isPhoneVerified: Boolean(normalizedPhone),
type: userType,
name: userInfo.firstName,
sender,
dv,
}

const user = await User.create(context, userData)

return await linkUser(context, user, userInfo)
}

const syncUser = async ({ context, userInfo, userType }) => {
const userIdentities = await UserExternalIdentity.getAll(context, {
identityType: TELEGRAM_IDP_TYPE,
identityId: String(userInfo.userId),
deletedAt: null,
}, 'id user { id }')

if (userIdentities.length > 0) {
const [identity] = userIdentities
const { user: { id } } = identity
return { id }
}

const existed = await User.getOne(context, {
phone: normalizePhone(userInfo.phoneNumber), type: userType,
})

if (existed) {
return await linkUser(context, existed, userInfo)
}
Comment on lines +62 to +64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

set isPhoneVerified: true for user?


return await registerUser(context, userInfo, userType)
}

module.exports = {
syncUser,
}
Loading
Loading