-
Notifications
You must be signed in to change notification settings - Fork 37
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
base: main
Are you sure you want to change the base?
Changes from 10 commits
6726733
35485ef
3b31396
95c8e10
baa21ef
7dfefb3
0d52dc4
d9733b2
d78dc54
f3bb09e
e8669ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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)) | ||
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}`) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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, | ||
} |
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() | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you going to run the bot in every pod for |
||||||
|
||||||
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), | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
} 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}`) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }) | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
} catch (error) { | ||||||
logger.error({ msg: 'Telegram auth status error', reqId: req.id, error }) | ||||||
next(error) | ||||||
} | ||||||
} | ||||||
} | ||||||
|
||||||
module.exports = { TelegramAuthRoutes } |
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' } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. set |
||
|
||
return await registerUser(context, userInfo, userType) | ||
} | ||
|
||
module.exports = { | ||
syncUser, | ||
} |
There was a problem hiding this comment.
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?