refactor(server): oauth only email, remove profile requests, support account linking state

This commit is contained in:
Kirill
2026-05-22 11:41:40 +05:00
parent bb7b40ac45
commit 5f180fffaf
+53 -62
View File
@@ -17,7 +17,7 @@ async function issueUserJwt(fastify, userId, email) {
return fastify.jwt.sign({ sub: userId, email })
}
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) {
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail, linkToUserId }) {
const existingLink = await prisma.oAuthAccount.findUnique({
where: { provider_providerUserId: { provider, providerUserId } },
include: { user: true },
@@ -34,6 +34,15 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
const norm = trimmed ? normalizeEmail(trimmed) : null
if (linkToUserId) {
if (!norm) return null
await prisma.oAuthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: linkToUserId, accessToken },
})
return prisma.user.findUnique({ where: { id: linkToUserId } })
}
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
if (user) {
await prisma.oAuthAccount.create({
@@ -42,16 +51,22 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
return user
}
let email = norm || `${provider}_${providerUserId}@oauth.craftshop.local`
let n = 0
while (await prisma.user.findUnique({ where: { email } })) {
n += 1
email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local`
}
user = await prisma.user.create({ data: { email } })
if (!norm) return null
user = await prisma.user.create({
data: {
email: norm,
displayName: norm.split('@')[0],
avatar: null,
avatarStyle: 'avataaars',
},
})
await prisma.oAuthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
})
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: true },
})
return user
}
@@ -85,9 +100,10 @@ export async function registerOAuthSocialRoutes(fastify) {
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
}
let statePayload = null
try {
const state = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '')
const raw = typeof query.state === 'string' ? query.state : ''
statePayload = fastify.jwt.verify(raw || '')
} catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
}
@@ -114,50 +130,26 @@ export async function registerOAuthSocialRoutes(fastify) {
const vkUserId = tokenBody?.user_id
const accessTokenVk = tokenBody?.access_token
let emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
let firstName = null
let lastName = null
let gender = null
let avatar = null
try {
if (accessTokenVk && vkUserId) {
const u = new URL('https://api.vk.com/method/users.get')
u.searchParams.set('access_token', accessTokenVk)
u.searchParams.set('users_ids', String(vkUserId))
u.searchParams.set('fields', 'photo_200,sex')
u.searchParams.set('v', '5.199')
const profRes = await fetch(u.toString())
const prof = await profRes.json()
const u0 = prof?.response?.[0]
if (u0) {
firstName = u0.first_name ?? null
lastName = u0.last_name ?? null
avatar = u0.photo_200 ?? null
if (u0.sex === 1) gender = 'female'
else if (u0.sex === 2) gender = 'male'
}
}
} catch {
// ignore profile extras
}
const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')
const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
const user = await findOrCreateUserFromOAuth({
provider: 'vk',
providerUserId: String(vkUserId),
accessToken: accessTokenVk ?? null,
suggestedEmail: emailSuggestion,
linkToUserId,
})
const displayName = [firstName, lastName].filter(Boolean).join(' ').trim()
const updateData = {}
if (displayName && !user.displayName) updateData.displayName = displayName
if (firstName) updateData.firstName = firstName
if (lastName) updateData.lastName = lastName
if (gender) updateData.gender = gender
if (avatar) updateData.avatar = avatar
if (Object.keys(updateData).length > 0) {
await prisma.user.update({ where: { id: user.id }, data: updateData })
if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK')
if (linkToUserId) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)
}
const token = await issueUserJwt(fastify, user.id, user.email)
@@ -176,7 +168,7 @@ export async function registerOAuthSocialRoutes(fastify) {
url.searchParams.set('response_type', 'code')
url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', redirectUri)
url.searchParams.set('scope', 'login:email login:info')
url.searchParams.set('scope', 'login:email')
url.searchParams.set('state', state)
return reply.redirect(url.toString())
@@ -186,9 +178,10 @@ export async function registerOAuthSocialRoutes(fastify) {
const query = request.query ?? {}
if (query.error) return oauthErrorRedirect(reply, String(query.error))
let statePayload = null
try {
const state = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '')
const raw = typeof query.state === 'string' ? query.state : ''
statePayload = fastify.jwt.verify(raw || '')
} catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
}
@@ -233,27 +226,25 @@ export async function registerOAuthSocialRoutes(fastify) {
const emailGuess =
(Array.isArray(info?.emails) && info.emails[0]) ||
info?.default_email ||
(info?.login ? `${info.login}@yandex.ru` : null)
null
if (!emailGuess) return oauthErrorRedirect(reply, 'no_email')
const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
const user = await findOrCreateUserFromOAuth({
provider: 'yandex',
providerUserId: yaUserId,
accessToken: yaToken,
suggestedEmail: emailGuess || null,
suggestedEmail: emailGuess,
linkToUserId,
})
const updateData = {}
const displayName =
[info.first_name, info.last_name].filter(Boolean).join(' ').trim() || info.display_name || info.real_name
if (displayName && !user.displayName) updateData.displayName = displayName
if (info.first_name) updateData.firstName = info.first_name
if (info.last_name) updateData.lastName = info.last_name
if (info.sex === 'male' || info.sex === 'female') updateData.gender = info.sex
if (info.default_avatar_id && !info.is_avatar_empty) {
updateData.avatar = `https://avatars.yandex.net/get-yapic/${info.default_avatar_id}/islands-200`
}
if (Object.keys(updateData).length > 0) {
await prisma.user.update({ where: { id: user.id }, data: updateData })
if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс')
if (linkToUserId) {
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`)
}
const token = await issueUserJwt(fastify, user.id, user.email)