refactor(server): oauth only email, remove profile requests, support account linking state
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user