This commit is contained in:
payacom
2026-08-31 20:45:41 +03:30
parent fa9f23c61d
commit 3f416a7a60
367 changed files with 621895 additions and 6443 deletions

View File

@@ -0,0 +1,267 @@
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { buildLoginResponseForUser } = require('../../../utils/loginResponse')
const {
expireBlockSuspensionIfNeeded,
getBlockedAccountResponse,
} = require('../../../utils/blockSuspension')
const {
findOrCreateGoogleUser,
findExistingGoogleUser,
} = require('../../../utils/googleUserAuth')
const { verifyGoogleIdToken } = require('../../../utils/googleTokenVerify')
const DEFAULT_GOOGLE_CLIENT_ID =
'91028483292-vba2ktgbvus1p5ce18ir04h1b5c50gnn.apps.googleusercontent.com'
function getGoogleClientIds() {
const raw =
process.env.GOOGLE_CLIENT_ID ||
process.env.GOOGLE_CLIENT_IDS ||
DEFAULT_GOOGLE_CLIENT_ID
return [...new Set(String(raw).split(',').map((id) => id.trim()).filter(Boolean))]
}
function googleAuthError(res, status, message, extra = {}) {
return res.status(status).json({
error: true,
message,
...extra,
})
}
const googleAuth = async (req, res) => {
try {
if (!process.env.APP_SECRET) {
console.error('[googleAuth] APP_SECRET is not configured')
return googleAuthError(
res,
503,
'پیکربندی سرور برای ورود با گوگل کامل نیست (APP_SECRET)'
)
}
const { credential } = req.body
if (!credential) {
return googleAuthError(res, 422, 'توکن گوگل ارسال نشده است')
}
const clientIds = getGoogleClientIds()
if (clientIds.length === 0) {
return googleAuthError(res, 503, 'ورود با گوگل در حال حاضر فعال نیست')
}
const payload = await verifyGoogleIdToken(credential, clientIds)
const googleId = payload?.sub
const email = payload?.email?.toLowerCase()?.trim()
if (!googleId || !email) {
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
}
if (!payload.email_verified) {
return googleAuthError(res, 422, 'ایمیل حساب گوگل تایید نشده است')
}
let user
try {
user = await findOrCreateGoogleUser({
googleId,
email,
})
} catch (err) {
if (err?.code === 'EMAIL_LINKED_TO_OTHER_GOOGLE') {
return googleAuthError(
res,
409,
'این ایمیل قبلاً با حساب گوگل دیگری ثبت شده است. با همان حساب وارد شوید یا از موبایل/نام کاربری استفاده کنید.'
)
}
if (err?.code === 'MISSING_GOOGLE_IDENTITY') {
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
}
// فقط duplicate key واقعی — نه هر MongoServerError
if (err?.code === 11000) {
user = await findExistingGoogleUser({ googleId, email })
if (!user) {
console.error('[googleAuth] duplicate key unresolved', {
googleId,
email,
keyValue: err.keyValue,
message: err.message,
})
return googleAuthError(
res,
409,
'ثبت این حساب گوگل ممکن نشد. اگر قبلاً ثبت‌نام کرده‌اید با همان روش وارد شوید؛ در غیر این صورت چند لحظه بعد دوباره تلاش کنید.',
{ en_message: err.message, keyValue: err.keyValue || null }
)
}
} else {
throw err
}
}
if (!user?._id) {
return googleAuthError(res, 500, 'ورود با گوگل ناموفق بود')
}
user = await expireBlockSuspensionIfNeeded(user)
const blockResponse = getBlockedAccountResponse(user)
if (blockResponse) {
return res.status(403).json(blockResponse)
}
const token = TokenService.sign({ id: String(user._id) })
const loginPayload = await buildLoginResponseForUser(user, token)
return res.json({
...loginPayload,
id: loginPayload.id != null ? String(loginPayload.id) : String(user._id),
})
} catch (error) {
console.error('[googleAuth] failed:', error)
if (
error?.name === 'TokenExpiredError' ||
error?.message?.includes('jwt expired') ||
error?.message?.includes('Token used too late')
) {
return googleAuthError(
res,
401,
'توکن گوگل منقضی شده است. دوباره تلاش کنید.'
)
}
if (
error?.name === 'JsonWebTokenError' ||
error?.message?.includes('Invalid token') ||
error?.message?.includes('jwt audience invalid') ||
error?.message?.includes('Signing key not found')
) {
return googleAuthError(
res,
401,
'توکن گوگل نامعتبر است. دوباره تلاش کنید.'
)
}
if (error?.message?.includes('JWKS unavailable')) {
return googleAuthError(
res,
503,
'سرور به Google دسترسی ندارد. فایل googleJwksCache.json را روی سرور آپلود کنید.',
{ en_message: error.message }
)
}
if (error?.name === 'ValidationError') {
return googleAuthError(
res,
422,
'ذخیره حساب گوگل ناموفق بود. مدل کاربر روی سرور به‌روز نیست.',
{ en_message: error.message }
)
}
return googleAuthError(
res,
500,
'خطا در ورود با گوگل. لطفاً چند لحظه بعد دوباره تلاش کنید.',
{ en_message: error.message }
)
}
}
const linkGoogleAccount = async (req, res) => {
try {
const user = req.user
const { credential } = req.body
if (!credential) {
return googleAuthError(res, 422, 'توکن گوگل ارسال نشده است')
}
if (user.google_id) {
return googleAuthError(res, 400, 'حساب گوگل قبلاً به این کاربر متصل شده است')
}
const clientIds = getGoogleClientIds()
if (clientIds.length === 0) {
return googleAuthError(res, 503, 'ورود با گوگل در حال حاضر فعال نیست')
}
const payload = await verifyGoogleIdToken(credential, clientIds)
const googleId = payload?.sub
const email = payload?.email?.toLowerCase()?.trim()
if (!googleId || !email) {
return googleAuthError(res, 422, 'اطلاعات حساب گوگل ناقص است')
}
if (!payload.email_verified) {
return googleAuthError(res, 422, 'ایمیل حساب گوگل تایید نشده است')
}
const conflict = await UserModel.findOne({
$or: [
{ google_id: googleId },
{ email },
{ email: new RegExp(`^${String(email).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') },
],
_id: { $ne: user._id },
})
if (conflict) {
return googleAuthError(
res,
409,
'این حساب گوگل یا ایمیل قبلاً به کاربر دیگری متصل شده است'
)
}
await UserModel.updateOne(
{ _id: user._id },
{
$set: {
google_id: googleId,
email: user.email || email,
email_verified: true,
},
},
{ strict: false }
)
return res.json({
message: 'حساب گوگل با موفقیت متصل شد',
email: user.email || email,
linked: true,
})
} catch (error) {
console.error('[linkGoogleAccount]', error)
return googleAuthError(res, 500, 'خطا در اتصال حساب گوگل')
}
}
const getGoogleLinkStatus = async (req, res) => {
try {
const user = req.user
return res.json({
linked: Boolean(user.google_id),
email: user.email || null,
auth_provider: user.auth_provider || 'mobile',
})
} catch (error) {
console.error('[getGoogleLinkStatus]', error)
return googleAuthError(res, 500, 'خطا در دریافت وضعیت حساب گوگل')
}
}
module.exports = {
googleAuth,
linkGoogleAccount,
getGoogleLinkStatus,
}

View File

@@ -0,0 +1,134 @@
const crypto = require('crypto')
const UserModel = require('../../../models/UserModel')
const PostModel = require('../../../models/PostModel')
const InstagramImportedPostModel = require('../../../models/InstagramImportedPostModel')
const { parseSignedRequest } = require('../../../utils/metaSignedRequest')
/**
* Meta calls these two endpoints server-to-server (no user session, no bearer
* token) — registered as the "Deauthorize callback URL" / "Data deletion request
* URL" in the Instagram Business Login settings. Auth is the signed_request's
* HMAC, not our normal middleware.
*/
function getAppSecret () {
return process.env.INSTAGRAM_APP_SECRET
}
async function findUserByInstagramScopedId (instagramId) {
if (!instagramId) return null
return UserModel.findOne({ instagram_id: String(instagramId) })
}
/**
* Same effect as the user-initiated "disconnect" button (instagramController.js):
* revoke the stored token, keep instagram_id so a future reconnect matches the
* same account. Shared so deauthorize/data-deletion/manual-disconnect can't drift.
*/
async function revokeInstagramConnection (userId) {
await UserModel.updateOne(
{ _id: userId },
{
$set: {
instagram_access_token_enc: null,
instagram_token_expires_at: null,
'instagram_sync_prefs.auto_update': false,
},
},
{ strict: false }
)
}
/**
* Deletes everything that was actually obtained *through* the Instagram API —
* imported posts + cached profile fields — without touching the rest of the
* Modstagram account (posts/content the user created directly in the app).
* instagram_id is $unset (not set to null): the field is a sparse unique index,
* and a sparse index still counts a literal `null` as present, so multiple
* deleted-and-relinked users would collide on a duplicate key.
*/
async function deleteInstagramSourcedData (userId) {
const importedPosts = await InstagramImportedPostModel.find({ user: userId }).select('post').lean()
const postIds = importedPosts.map((row) => row.post)
if (postIds.length) {
await PostModel.deleteMany({ _id: { $in: postIds } })
}
await InstagramImportedPostModel.deleteMany({ user: userId })
await UserModel.updateOne(
{ _id: userId },
{
$set: {
instagram_username: null,
instagram_account_type: null,
instagram_access_token_enc: null,
instagram_token_expires_at: null,
instagram_connected_at: null,
instagram_last_synced_at: null,
'instagram_sync_prefs.posts': false,
'instagram_sync_prefs.auto_update': false,
},
$unset: { instagram_id: '' },
},
{ strict: false }
)
}
const instagramDeauthorize = async (req, res) => {
const secret = getAppSecret()
if (!secret) {
console.error('[instagramDeauthorize] INSTAGRAM_APP_SECRET is not configured')
return res.status(503).end()
}
const data = parseSignedRequest(req.body?.signed_request, secret)
if (!data?.user_id) {
return res.status(400).json({ error: true, message: 'invalid signed_request' })
}
try {
const user = await findUserByInstagramScopedId(data.user_id)
if (user) {
await revokeInstagramConnection(user._id)
}
} catch (err) {
console.error('[instagramDeauthorize]', err)
}
return res.status(200).json({ success: true })
}
const instagramDataDeletion = async (req, res) => {
const secret = getAppSecret()
if (!secret) {
console.error('[instagramDataDeletion] INSTAGRAM_APP_SECRET is not configured')
return res.status(503).end()
}
const data = parseSignedRequest(req.body?.signed_request, secret)
if (!data?.user_id) {
return res.status(400).json({ error: true, message: 'invalid signed_request' })
}
const confirmationCode = crypto.randomBytes(16).toString('hex')
try {
const user = await findUserByInstagramScopedId(data.user_id)
if (user) {
await deleteInstagramSourcedData(user._id)
}
} catch (err) {
console.error('[instagramDataDeletion]', err)
}
const appSite = process.env.APP_SITE || 'https://modstagram.com'
return res.status(200).json({
url: `${appSite}/instagram-data-deletion?code=${confirmationCode}`,
confirmation_code: confirmationCode,
})
}
module.exports = {
instagramDeauthorize,
instagramDataDeletion,
}

View File

@@ -0,0 +1,285 @@
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { buildLoginResponseForUser } = require('../../../utils/loginResponse')
const {
expireBlockSuspensionIfNeeded,
getBlockedAccountResponse,
} = require('../../../utils/blockSuspension')
const {
findOrCreateInstagramUser,
} = require('../../../utils/instagramUserAuth')
const InstagramGraphService = require('../../../services/InstagramGraphService')
const { encryptSecret } = require('../../../utils/secretCipher')
const ALLOWED_ACCOUNT_TYPES = ['BUSINESS', 'MEDIA_CREATOR']
function instagramAuthError(res, status, message, extra = {}) {
return res.status(status).json({
error: true,
message,
...extra,
})
}
function isConfigured() {
return Boolean(
process.env.INSTAGRAM_CLIENT_ID &&
process.env.INSTAGRAM_APP_SECRET &&
process.env.INSTAGRAM_REDIRECT_URI
)
}
/**
* Exchange the OAuth `code` for a long-lived token + profile.
* Shared by both the login/register callback and the link-to-existing-user flow.
*/
async function resolveInstagramIdentity(code) {
const { accessToken: shortLivedToken } = await InstagramGraphService.exchangeCodeForToken(code)
if (!shortLivedToken) {
const err = new Error('NO_SHORT_LIVED_TOKEN')
err.code = 'NO_SHORT_LIVED_TOKEN'
throw err
}
const { accessToken: longLivedToken, expiresAt } =
await InstagramGraphService.exchangeForLongLivedToken(shortLivedToken)
const profile = await InstagramGraphService.getProfile(longLivedToken)
return { longLivedToken, expiresAt, profile }
}
const instagramAuth = async (req, res) => {
try {
if (!process.env.APP_SECRET) {
console.error('[instagramAuth] APP_SECRET is not configured')
return instagramAuthError(
res,
503,
'پیکربندی سرور کامل نیست (APP_SECRET)'
)
}
if (!isConfigured()) {
return instagramAuthError(res, 503, 'ورود با اینستاگرام در حال حاضر فعال نیست')
}
const { code } = req.body
if (!code) {
return instagramAuthError(res, 422, 'کد اینستاگرام ارسال نشده است')
}
let longLivedToken, expiresAt, profile
try {
;({ longLivedToken, expiresAt, profile } = await resolveInstagramIdentity(code))
} catch (err) {
console.error('[instagramAuth] token exchange failed:', err?.response?.data || err.message)
return instagramAuthError(
res,
401,
'اتصال به اینستاگرام ناموفق بود. دوباره تلاش کنید.',
{ en_message: err?.response?.data?.error_message || err.message }
)
}
if (!profile?.id) {
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
}
if (profile.accountType && !ALLOWED_ACCOUNT_TYPES.includes(profile.accountType)) {
return instagramAuthError(
res,
422,
'فقط حساب‌های Business یا Creator اینستاگرام قابل اتصال هستند. لطفاً حساب خود را در اینستاگرام به یکی از این دو نوع تغییر دهید.'
)
}
let user
try {
user = await findOrCreateInstagramUser({
instagramId: profile.id,
username: profile.username,
accountType: profile.accountType,
tokenEnc: encryptSecret(longLivedToken),
tokenExpiresAt: expiresAt,
profilePictureUrl: profile.profilePictureUrl,
})
} catch (err) {
if (err?.code === 'MISSING_INSTAGRAM_IDENTITY') {
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
}
throw err
}
if (!user?._id) {
return instagramAuthError(res, 500, 'ورود با اینستاگرام ناموفق بود')
}
user = await expireBlockSuspensionIfNeeded(user)
const blockResponse = getBlockedAccountResponse(user)
if (blockResponse) {
return res.status(403).json(blockResponse)
}
const token = TokenService.sign({ id: String(user._id) })
const loginPayload = await buildLoginResponseForUser(user, token)
return res.json({
...loginPayload,
id: loginPayload.id != null ? String(loginPayload.id) : String(user._id),
instagram_username: profile.username || null,
instagram_full_name: profile.name || null,
instagram_bio: profile.biography || null,
instagram_profile_picture_url: profile.profilePictureUrl || null,
})
} catch (error) {
console.error('[instagramAuth] failed:', error)
return instagramAuthError(
res,
500,
'خطا در ورود با اینستاگرام. لطفاً چند لحظه بعد دوباره تلاش کنید.',
{ en_message: error.message }
)
}
}
const linkInstagramAccount = async (req, res) => {
try {
const user = req.user
const { code } = req.body
if (!code) {
return instagramAuthError(res, 422, 'کد اینستاگرام ارسال نشده است')
}
if (user.instagram_id && user.instagram_access_token_enc) {
return instagramAuthError(res, 400, 'حساب اینستاگرام قبلاً به این کاربر متصل شده است')
}
if (!isConfigured()) {
return instagramAuthError(res, 503, 'اتصال اینستاگرام در حال حاضر فعال نیست')
}
let longLivedToken, expiresAt, profile
try {
;({ longLivedToken, expiresAt, profile } = await resolveInstagramIdentity(code))
} catch (err) {
console.error('[linkInstagramAccount] token exchange failed:', err?.response?.data || err.message)
return instagramAuthError(
res,
401,
'اتصال به اینستاگرام ناموفق بود. دوباره تلاش کنید.',
{ en_message: err?.response?.data?.error_message || err.message }
)
}
if (!profile?.id) {
return instagramAuthError(res, 422, 'اطلاعات حساب اینستاگرام ناقص است')
}
if (profile.accountType && !ALLOWED_ACCOUNT_TYPES.includes(profile.accountType)) {
return instagramAuthError(
res,
422,
'فقط حساب‌های Business یا Creator اینستاگرام قابل اتصال هستند. لطفاً حساب خود را در اینستاگرام به یکی از این دو نوع تغییر دهید.'
)
}
const conflict = await UserModel.findOne({
instagram_id: String(profile.id),
_id: { $ne: user._id },
})
if (conflict) {
return instagramAuthError(
res,
409,
'این حساب اینستاگرام قبلاً به کاربر دیگری متصل شده است'
)
}
await UserModel.updateOne(
{ _id: user._id },
{
$set: {
instagram_id: String(profile.id),
instagram_username: profile.username || null,
instagram_account_type: profile.accountType || null,
instagram_access_token_enc: encryptSecret(longLivedToken),
instagram_token_expires_at: expiresAt,
instagram_connected_at: new Date(),
},
},
{ strict: false }
)
return res.json({
message: 'حساب اینستاگرام با موفقیت متصل شد',
instagram_username: profile.username || null,
linked: true,
})
} catch (error) {
console.error('[linkInstagramAccount]', error)
return instagramAuthError(res, 500, 'خطا در اتصال حساب اینستاگرام')
}
}
const getInstagramLinkStatus = async (req, res) => {
try {
const user = req.user
return res.json({
linked: Boolean(user.instagram_id),
connected: Boolean(user.instagram_id && user.instagram_access_token_enc),
instagram_username: user.instagram_username || null,
instagram_account_type: user.instagram_account_type || null,
connected_at: user.instagram_connected_at || null,
auth_provider: user.auth_provider || 'mobile',
instagram_sync_prefs: {
posts: Boolean(user.instagram_sync_prefs?.posts),
auto_update: Boolean(user.instagram_sync_prefs?.auto_update),
},
})
} catch (error) {
console.error('[getInstagramLinkStatus]', error)
return instagramAuthError(res, 500, 'خطا در دریافت وضعیت حساب اینستاگرام')
}
}
const disconnectInstagram = async (req, res) => {
try {
const user = req.user
if (!user.instagram_id) {
return instagramAuthError(res, 400, 'حساب اینستاگرامی متصل نیست')
}
// instagram_id عمداً حذف نمی‌شود تا محتوای قبلاً ایمپورت‌شده و امکان اتصال مجدد حفظ شود
await UserModel.updateOne(
{ _id: user._id },
{
$set: {
instagram_access_token_enc: null,
instagram_token_expires_at: null,
'instagram_sync_prefs.auto_update': false,
},
},
{ strict: false }
)
return res.json({
message: 'اتصال اینستاگرام قطع شد',
linked: true,
connected: false,
})
} catch (error) {
console.error('[disconnectInstagram]', error)
return instagramAuthError(res, 500, 'خطا در قطع اتصال اینستاگرام')
}
}
module.exports = {
instagramAuth,
linkInstagramAccount,
getInstagramLinkStatus,
disconnectInstagram,
}

View File

@@ -0,0 +1,141 @@
const UserModel = require('../../../models/UserModel')
const InstagramImportedPostModel = require('../../../models/InstagramImportedPostModel')
const InstagramGraphService = require('../../../services/InstagramGraphService')
const { decryptSecret } = require('../../../utils/secretCipher')
const { importInstagramMedia, SUPPORTED_MEDIA_TYPES } = require('../../../utils/instagramMediaImport')
function importError (res, status, message) {
return res.status(status).json({ error: true, message })
}
async function requireConnectedInstagram (user) {
if (!user.instagram_id || !user.instagram_access_token_enc) {
const err = new Error('NOT_CONNECTED')
err.code = 'NOT_CONNECTED'
throw err
}
return decryptSecret(user.instagram_access_token_enc)
}
/**
* List the user's Instagram media, flagged with whether each item was already
* imported — feeds the manual picker UI.
*/
const getInstagramMedia = async (req, res) => {
try {
const user = req.user
let accessToken
try {
accessToken = await requireConnectedInstagram(user)
} catch (err) {
if (err.code === 'NOT_CONNECTED') {
return importError(res, 400, 'ابتدا حساب اینستاگرام خود را متصل کنید')
}
throw err
}
const { items } = await InstagramGraphService.getMedia(accessToken)
const importedIds = new Set(
(
await InstagramImportedPostModel.find({ user: user._id }).select('instagram_media_id').lean()
).map((row) => row.instagram_media_id)
)
const media = items.map((item) => ({
id: item.id,
caption: item.caption || null,
media_type: item.media_type,
media_url: item.media_url,
permalink: item.permalink,
timestamp: item.timestamp,
supported: SUPPORTED_MEDIA_TYPES.includes(item.media_type),
already_imported: importedIds.has(String(item.id)),
}))
return res.json({ media })
} catch (error) {
console.error('[getInstagramMedia]', error)
return importError(res, 500, 'خطا در دریافت پست‌های اینستاگرام')
}
}
/**
* mode: 'manual' — import exactly the given mediaIds.
* mode: 'automatic' — turn on ongoing sync and import everything not yet imported.
*/
const importInstagramPosts = async (req, res) => {
try {
const user = req.user
const { mode, mediaIds } = req.body
if (mode !== 'manual' && mode !== 'automatic') {
return importError(res, 422, 'نوع انتقال نامعتبر است')
}
let accessToken
try {
accessToken = await requireConnectedInstagram(user)
} catch (err) {
if (err.code === 'NOT_CONNECTED') {
return importError(res, 400, 'ابتدا حساب اینستاگرام خود را متصل کنید')
}
throw err
}
const { items } = await InstagramGraphService.getMedia(accessToken)
let targetItems
if (mode === 'manual') {
const requestedIds = new Set((Array.isArray(mediaIds) ? mediaIds : []).map(String))
if (requestedIds.size === 0) {
return importError(res, 422, 'هیچ پستی انتخاب نشده است')
}
targetItems = items.filter((item) => requestedIds.has(String(item.id)))
} else {
targetItems = items
}
let importedCount = 0
for (const item of targetItems) {
const post = await importInstagramMedia(user, item)
if (post) importedCount += 1
}
const $set = { 'instagram_sync_prefs.posts': true }
if (mode === 'automatic') {
$set['instagram_sync_prefs.auto_update'] = true
$set.instagram_last_synced_at = new Date()
}
await UserModel.updateOne({ _id: user._id }, { $set }, { strict: false })
return res.json({
message: 'انتقال پست‌های اینستاگرام انجام شد',
imported_count: importedCount,
auto_update: mode === 'automatic',
})
} catch (error) {
console.error('[importInstagramPosts]', error)
return importError(res, 500, 'خطا در انتقال پست‌های اینستاگرام')
}
}
const disableAutoImport = async (req, res) => {
try {
const user = req.user
await UserModel.updateOne(
{ _id: user._id },
{ $set: { 'instagram_sync_prefs.auto_update': false } },
{ strict: false }
)
return res.json({ message: 'همگام‌سازی خودکار غیرفعال شد', auto_update: false })
} catch (error) {
console.error('[disableAutoImport]', error)
return importError(res, 500, 'خطا در غیرفعال‌سازی همگام‌سازی خودکار')
}
}
module.exports = {
getInstagramMedia,
importInstagramPosts,
disableAutoImport,
}

View File

@@ -0,0 +1,148 @@
const UserModel = require('../../../models/UserModel')
const TokenService = require('../../../services/TokenService')
const { buildAuthResponse } = require('../../../utils/authSteps')
const { verifyTemp2faToken } = require('../../../utils/loginResponse')
const {
generateSecret,
verifyTotp,
buildOtpAuthUrl,
} = require('../../../utils/totp')
const {
readTotpState,
setPendingSecret,
activateTotp,
deactivateTotp,
} = require('../../../utils/totpStore')
const ISSUER = 'Modstagram'
async function getTotpStatus (req, res) {
const user = req.user
const state = await readTotpState(user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
return res.json({
enabled: state.totp_enabled,
linked: Boolean(state.totp_secret),
})
}
async function setupTotp (req, res) {
const user = req.user
if (!user?._id) {
return res.status(401).json({ message: 'کاربر احراز هویت نشده است' })
}
const state = await readTotpState(user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (state.totp_enabled) {
return res.status(400).json({ message: 'ورود دو مرحله‌ای قبلاً فعال شده است' })
}
const secret = generateSecret()
await setPendingSecret(user._id, secret)
const accountName =
user.user_name || user.email || user.mobile || String(user._id)
return res.json({
secret,
otpauth_url: buildOtpAuthUrl({
secret,
accountName,
issuer: ISSUER,
}),
})
}
async function enableTotp (req, res) {
const { code } = req.body
if (!code) {
return res.status(422).json({ message: 'کد تایید الزامی است' })
}
const state = await readTotpState(req.user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (!state.totp_pending_secret) {
return res.status(400).json({ message: 'ابتدا راه‌اندازی ورود دو مرحله‌ای را انجام دهید' })
}
if (!verifyTotp(state.totp_pending_secret, code)) {
return res.status(422).json({ message: 'کد تایید نامعتبر است' })
}
await activateTotp(req.user._id, state.totp_pending_secret)
return res.json({ message: 'ورود دو مرحله‌ای فعال شد', enabled: true })
}
async function disableTotp (req, res) {
const { code } = req.body
const state = await readTotpState(req.user._id)
if (!state) {
return res.status(404).json({ message: 'کاربر یافت نشد' })
}
if (!state.totp_enabled || !state.totp_secret) {
return res.status(400).json({ message: 'ورود دو مرحله‌ای فعال نیست' })
}
if (!code || !verifyTotp(state.totp_secret, code)) {
return res.status(422).json({ message: 'کد تایید نامعتبر است' })
}
await deactivateTotp(req.user._id)
return res.json({ message: 'ورود دو مرحله‌ای غیرفعال شد', enabled: false })
}
async function verifyLoginTotp (req, res) {
const { temp_token: tempToken, code } = req.body
if (!tempToken || !code) {
return res.status(422).json({ message: 'اطلاعات ورود ناقص است' })
}
let decoded
try {
decoded = verifyTemp2faToken(tempToken)
} catch {
return res.status(401).json({ message: 'نشست ورود منقضی شده است. دوباره وارد شوید' })
}
const state = await readTotpState(decoded.id)
if (!state?.totp_enabled || !state.totp_secret) {
return res.status(401).json({ message: 'ورود دو مرحله‌ای برای این حساب فعال نیست' })
}
if (!verifyTotp(state.totp_secret, code)) {
return res.status(422).json({ message: 'کد دو مرحله‌ای نامعتبر است' })
}
const user = await UserModel.findById(decoded.id)
if (!user) {
return res.status(401).json({ message: 'کاربر یافت نشد' })
}
const token = TokenService.sign({ id: user._id })
return res.json(buildAuthResponse(user, token))
}
module.exports = {
getTotpStatus,
setupTotp,
enableTotp,
disableTotp,
verifyLoginTotp,
}