base commit
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { normalizeEmail } from '../lib/auth.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
|
||||
function clientRedirect(fastify, reply, token) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
const url = `${base.replace(/\/$/, '')}/auth/callback?token=${encodeURIComponent(token)}`
|
||||
return reply.redirect(url)
|
||||
}
|
||||
|
||||
function oauthErrorRedirect(reply, msg) {
|
||||
const base = process.env.CLIENT_PUBLIC_URL || 'http://127.0.0.1:5173'
|
||||
const url = `${base.replace(/\/$/, '')}/auth?oauthError=${encodeURIComponent(msg)}`
|
||||
return reply.redirect(url)
|
||||
}
|
||||
|
||||
async function issueUserJwt(fastify, userId, email) {
|
||||
return fastify.jwt.sign({ sub: userId, email })
|
||||
}
|
||||
|
||||
async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken, suggestedEmail }) {
|
||||
const existingLink = await prisma.oauthAccount.findUnique({
|
||||
where: { provider_providerUserId: { provider, providerUserId } },
|
||||
include: { user: true },
|
||||
})
|
||||
if (existingLink?.user) {
|
||||
if (accessToken !== undefined) {
|
||||
await prisma.oauthAccount.update({
|
||||
where: { provider_providerUserId: { provider, providerUserId } },
|
||||
data: { accessToken },
|
||||
})
|
||||
}
|
||||
return existingLink.user
|
||||
}
|
||||
|
||||
const trimmed = typeof suggestedEmail === 'string' ? suggestedEmail.trim() : ''
|
||||
const norm = trimmed ? normalizeEmail(trimmed) : null
|
||||
let user = norm ? await prisma.user.findUnique({ where: { email: norm } }) : null
|
||||
if (user) {
|
||||
await prisma.oauthAccount.create({
|
||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, 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 } })
|
||||
await prisma.oauthAccount.create({
|
||||
data: { provider, providerUserId: String(providerUserId), userId: user.id, accessToken },
|
||||
})
|
||||
return user
|
||||
}
|
||||
|
||||
export async function registerOAuthSocialRoutes(fastify) {
|
||||
const serverPublic = process.env.SERVER_PUBLIC_URL || 'http://127.0.0.1:3333'
|
||||
|
||||
/** --- VK --- */
|
||||
fastify.get('/api/auth/oauth/vk', async (_request, reply) => {
|
||||
const clientId = process.env.VK_CLIENT_ID
|
||||
const clientSecret = process.env.VK_CLIENT_SECRET
|
||||
if (!clientId || !clientSecret) return reply.code(503).send({ error: 'VK OAuth не настроен (нет VK_* в env)' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
||||
const state = fastify.jwt.sign({ oauth: 'vk' }, { expiresIn: '15m' })
|
||||
|
||||
const url = new URL('https://oauth.vk.com/authorize')
|
||||
url.searchParams.set('client_id', clientId)
|
||||
url.searchParams.set('display', 'page')
|
||||
url.searchParams.set('redirect_uri', redirectUri)
|
||||
url.searchParams.set('scope', 'email')
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('v', '5.199')
|
||||
url.searchParams.set('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/vk/callback', async (request, reply) => {
|
||||
const query = request.query ?? {}
|
||||
if (query.error || query.error_description) {
|
||||
return oauthErrorRedirect(reply, String(query.error_description || query.error || 'ошибка VK'))
|
||||
}
|
||||
|
||||
try {
|
||||
const state = typeof query.state === 'string' ? query.state : ''
|
||||
fastify.jwt.verify(state || '')
|
||||
} catch {
|
||||
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
||||
}
|
||||
|
||||
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
||||
if (!code) return oauthErrorRedirect(reply, 'Не получен код от VK')
|
||||
|
||||
const clientId = process.env.VK_CLIENT_ID
|
||||
const clientSecret = process.env.VK_CLIENT_SECRET
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/vk/callback`
|
||||
|
||||
const tokenUrl = new URL('https://oauth.vk.com/access_token')
|
||||
tokenUrl.searchParams.set('client_id', clientId)
|
||||
tokenUrl.searchParams.set('client_secret', clientSecret)
|
||||
tokenUrl.searchParams.set('redirect_uri', redirectUri)
|
||||
tokenUrl.searchParams.set('code', code)
|
||||
|
||||
const tokenRes = await fetch(tokenUrl.toString())
|
||||
const tokenBody = await tokenRes.json()
|
||||
|
||||
if (tokenBody?.error_description || tokenBody?.error || !tokenRes.ok) {
|
||||
return oauthErrorRedirect(reply, tokenBody?.error_description || tokenBody?.error || 'Не удалось обменять код VK')
|
||||
}
|
||||
|
||||
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
|
||||
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_50')
|
||||
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
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore profile extras
|
||||
}
|
||||
|
||||
const user = await findOrCreateUserFromOAuth({
|
||||
provider: 'vk',
|
||||
providerUserId: String(vkUserId),
|
||||
accessToken: accessTokenVk ?? null,
|
||||
suggestedEmail: emailSuggestion,
|
||||
})
|
||||
|
||||
if (firstName || lastName) {
|
||||
const name = [firstName, lastName].filter(Boolean).join(' ').trim()
|
||||
if (name && !user.name) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { name } })
|
||||
}
|
||||
}
|
||||
|
||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||
return clientRedirect(fastify, reply, token)
|
||||
})
|
||||
|
||||
/** --- Yandex --- */
|
||||
fastify.get('/api/auth/oauth/yandex', async (_request, reply) => {
|
||||
const clientId = process.env.YANDEX_CLIENT_ID
|
||||
if (!clientId) return reply.code(503).send({ error: 'Yandex OAuth не настроен (нет YANDEX_* в env)' })
|
||||
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
||||
const state = fastify.jwt.sign({ oauth: 'yandex' }, { expiresIn: '15m' })
|
||||
|
||||
const url = new URL('https://oauth.yandex.ru/authorize')
|
||||
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('state', state)
|
||||
|
||||
return reply.redirect(url.toString())
|
||||
})
|
||||
|
||||
fastify.get('/api/auth/oauth/yandex/callback', async (request, reply) => {
|
||||
const query = request.query ?? {}
|
||||
if (query.error) return oauthErrorRedirect(reply, String(query.error))
|
||||
|
||||
try {
|
||||
const state = typeof query.state === 'string' ? query.state : ''
|
||||
fastify.jwt.verify(state || '')
|
||||
} catch {
|
||||
return oauthErrorRedirect(reply, 'Недействительный state OAuth')
|
||||
}
|
||||
|
||||
const code = typeof query.code === 'string' ? query.code.trim() : ''
|
||||
if (!code) return oauthErrorRedirect(reply, 'Не получен код от Яндекс')
|
||||
|
||||
const clientId = process.env.YANDEX_CLIENT_ID
|
||||
const clientSecret = process.env.YANDEX_CLIENT_SECRET
|
||||
const redirectUri = `${serverPublic}/api/auth/oauth/yandex/callback`
|
||||
|
||||
const body = new URLSearchParams()
|
||||
body.set('grant_type', 'authorization_code')
|
||||
body.set('code', code)
|
||||
body.set('client_id', clientId)
|
||||
body.set('client_secret', clientSecret)
|
||||
if (redirectUri) body.set('redirect_uri', redirectUri)
|
||||
|
||||
const tokenRes = await fetch('https://oauth.yandex.ru/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
})
|
||||
const tokenBody = await tokenRes.json()
|
||||
|
||||
if (!tokenRes.ok || !tokenBody.access_token) {
|
||||
return oauthErrorRedirect(
|
||||
reply,
|
||||
tokenBody.error_description || tokenBody.error || 'Не удалось обменять код Yandex',
|
||||
)
|
||||
}
|
||||
|
||||
const yaToken = tokenBody.access_token
|
||||
|
||||
const infoRes = await fetch('https://login.yandex.ru/info', {
|
||||
headers: { Authorization: `OAuth ${yaToken}` },
|
||||
})
|
||||
const info = await infoRes.json()
|
||||
const yaUserId = String(info?.id || '')
|
||||
if (!yaUserId) return oauthErrorRedirect(reply, 'Не удалось получить профиль Yandex')
|
||||
|
||||
const emailGuess =
|
||||
(Array.isArray(info?.emails) && info.emails[0]) ||
|
||||
info?.default_email ||
|
||||
(info?.login ? `${info.login}@yandex.ru` : null)
|
||||
|
||||
const user = await findOrCreateUserFromOAuth({
|
||||
provider: 'yandex',
|
||||
providerUserId: yaUserId,
|
||||
accessToken: yaToken,
|
||||
suggestedEmail: emailGuess || null,
|
||||
})
|
||||
|
||||
const dn = `${info.first_name ?? ''} ${info.last_name ?? ''}`.trim()
|
||||
if (dn && !user.name) {
|
||||
await prisma.user.update({ where: { id: user.id }, data: { name: dn } })
|
||||
}
|
||||
|
||||
const token = await issueUserJwt(fastify, user.id, user.email)
|
||||
return clientRedirect(fastify, reply, token)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user