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 }) 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({ const existingLink = await prisma.oAuthAccount.findUnique({
where: { provider_providerUserId: { provider, providerUserId } }, where: { provider_providerUserId: { provider, providerUserId } },
include: { user: true }, include: { user: true },
@@ -34,6 +34,15 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : '' const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
const norm = trimmed ? normalizeEmail(trimmed) : null 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 let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
if (user) { if (user) {
await prisma.oAuthAccount.create({ await prisma.oAuthAccount.create({
@@ -42,16 +51,22 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken
return user return user
} }
let email = norm || `${provider}_${providerUserId}@oauth.craftshop.local` if (!norm) return null
let n = 0
while (await prisma.user.findUnique({ where: { email } })) { user = await prisma.user.create({
n += 1 data: {
email = `${provider}_${providerUserId}_${n}@oauth.craftshop.local` email: norm,
} displayName: norm.split('@')[0],
user = await prisma.user.create({ data: { email } }) avatar: null,
avatarStyle: 'avataaars',
},
})
await prisma.oAuthAccount.create({ await prisma.oAuthAccount.create({
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken }, data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
}) })
await prisma.notificationPreference.create({
data: { userId: user.id, globalEnabled: true },
})
return user return user
} }
@@ -85,9 +100,10 @@ export async function registerOAuthSocialRoutes(fastify) {
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK')) return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
} }
let statePayload = null
try { try {
const state = typeof query.state === 'string' ? query.state : '' const raw = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '') statePayload = fastify.jwt.verify(raw || '')
} catch { } catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth') return oauthErrorRedirect(reply, 'Недействительный state OAuth')
} }
@@ -114,50 +130,26 @@ export async function registerOAuthSocialRoutes(fastify) {
const vkUserId = tokenBody?.user_id const vkUserId = tokenBody?.user_id
const accessTokenVk = tokenBody?.access_token const accessTokenVk = tokenBody?.access_token
let emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
let firstName = null const emailSuggestion = typeof tokenBody?.email === 'string' ? tokenBody.email : null
let lastName = null
let gender = null if (!emailSuggestion) return oauthErrorRedirect(reply, 'no_email')
let avatar = null
try { const linkToUserId = statePayload?.action === 'link' ? statePayload.userId : undefined
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 user = await findOrCreateUserFromOAuth({ const user = await findOrCreateUserFromOAuth({
provider: 'vk', provider: 'vk',
providerUserId: String(vkUserId), providerUserId: String(vkUserId),
accessToken: accessTokenVk ?? null, accessToken: accessTokenVk ?? null,
suggestedEmail: emailSuggestion, suggestedEmail: emailSuggestion,
linkToUserId,
}) })
const displayName = [firstName, lastName].filter(Boolean).join(' ').trim() if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от VK')
const updateData = {}
if (displayName && !user.displayName) updateData.displayName = displayName if (linkToUserId) {
if (firstName) updateData.firstName = firstName const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
if (lastName) updateData.lastName = lastName return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=vk`)
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 })
} }
const token = await issueUserJwt(fastify, user.id, user.email) 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('response_type', 'code')
url.searchParams.set('client_id', clientId) url.searchParams.set('client_id', clientId)
url.searchParams.set('redirect_uri', redirectUri) 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) url.searchParams.set('state', state)
return reply.redirect(url.toString()) return reply.redirect(url.toString())
@@ -186,9 +178,10 @@ export async function registerOAuthSocialRoutes(fastify) {
const query = request.query ?? {} const query = request.query ?? {}
if (query.error) return oauthErrorRedirect(reply, String(query.error)) if (query.error) return oauthErrorRedirect(reply, String(query.error))
let statePayload = null
try { try {
const state = typeof query.state === 'string' ? query.state : '' const raw = typeof query.state === 'string' ? query.state : ''
fastify.jwt.verify(state || '') statePayload = fastify.jwt.verify(raw || '')
} catch { } catch {
return oauthErrorRedirect(reply, 'Недействительный state OAuth') return oauthErrorRedirect(reply, 'Недействительный state OAuth')
} }
@@ -233,27 +226,25 @@ export async function registerOAuthSocialRoutes(fastify) {
const emailGuess = const emailGuess =
(Array.isArray(info?.emails) && info.emails[0]) || (Array.isArray(info?.emails) && info.emails[0]) ||
info?.default_email || 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({ const user = await findOrCreateUserFromOAuth({
provider: 'yandex', provider: 'yandex',
providerUserId: yaUserId, providerUserId: yaUserId,
accessToken: yaToken, accessToken: yaToken,
suggestedEmail: emailGuess || null, suggestedEmail: emailGuess,
linkToUserId,
}) })
const updateData = {} if (!user) return oauthErrorRedirect(reply, 'Не удалось получить email от Яндекс')
const displayName =
[info.first_name, info.last_name].filter(Boolean).join(' ').trim() || info.display_name || info.real_name if (linkToUserId) {
if (displayName && !user.displayName) updateData.displayName = displayName const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
if (info.first_name) updateData.firstName = info.first_name return reply.redirect(`${base.replace(/\/$/, '')}/me?linked=yandex`)
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 })
} }
const token = await issueUserJwt(fastify, user.id, user.email) const token = await issueUserJwt(fastify, user.id, user.email)