Browse Source

✨ feat: 加入第三方账号绑定功能

Pchen0 1 month ago
parent
commit
fe5b177602

+ 39 - 1
apis/User/Admin/GetUserList.js

@@ -52,7 +52,45 @@ class GetUserList extends API {
 
         let sql = `
             SELECT
-                uuid, username, nickname, email, registTime, avatar, lepao_count, social_type, lastTime
+                uuid,
+                username,
+                nickname,
+                email,
+                registTime,
+                avatar,
+                lepao_count,
+                (
+                    SELECT lh.type
+                    FROM login_history lh
+                    WHERE lh.uuid = users.uuid
+                    ORDER BY lh.time DESC
+                    LIMIT 1
+                ) AS last_login_type,
+                (
+                    SELECT usb.social_nickname
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'qq'
+                    LIMIT 1
+                ) AS qq_social_nickname,
+                (
+                    SELECT usb.social_avatar
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'qq'
+                    LIMIT 1
+                ) AS qq_social_avatar,
+                (
+                    SELECT usb.social_nickname
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'wx'
+                    LIMIT 1
+                ) AS wx_social_nickname,
+                (
+                    SELECT usb.social_avatar
+                    FROM user_social_bindings usb
+                    WHERE usb.user_uuid = users.uuid AND usb.social_type = 'wx'
+                    LIMIT 1
+                ) AS wx_social_avatar,
+                lastTime
             FROM
                 users
             WHERE 

+ 5 - 1
apis/User/GetUserInfo.js

@@ -3,6 +3,7 @@ const db = require("../../plugin/DataBase/db")
 const AccessControl = require("../../lib/AccessControl")
 const Redis = require('../../plugin/DataBase/Redis')
 const { BaseStdResponse } = require("../../BaseStdResponse")
+const { getUserSocialBindings, toSocialBindingSummary } = require("../../lib/UserSocialBinding")
 
 class GetRepoList extends API {
     constructor() {
@@ -39,12 +40,15 @@ class GetRepoList extends API {
             })
 
         const userSession = await Redis.get(`userSession:${uuid}`)
+        const bindings = await getUserSocialBindings(uuid)
 
         res.json({
             ...BaseStdResponse.OK,
             data: {
                 ...rows[0],
-                session: userSession
+                session: userSession,
+                socialBindings: toSocialBindingSummary(bindings),
+                boundTypes: bindings.map(binding => binding.social_type)
             }
         })
     }

+ 13 - 0
apis/User/Login.js

@@ -75,6 +75,19 @@ class Login extends API {
         const time = new Date().getTime()
         sql = 'UPDATE users SET lastTime = ? WHERE id = ?';
         await db.query(sql, [time, rows[0].id]);
+
+        try {
+            let ip = req.headers['x-forwarded-for']?.split(',')[0].trim() || req.connection?.remoteAddress || ''
+            if (ip.startsWith('::ffff:'))
+                ip = ip.replace('::ffff:', '')
+            const userAgent = req.headers['user-agent']
+            await db.query(
+                'INSERT INTO login_history (uuid, time, deviceInfo, type, ip) VALUES (?, ?, ?, ?, ?)',
+                [rows[0].uuid, time, { ua: userAgent }, 'password', ip]
+            )
+        } catch (err) {
+            this.logger.error(`写入登录记录失败!${err}`)
+        }
     }
 }
 

+ 92 - 0
apis/User/uniLogin/BindSocial.js

@@ -0,0 +1,92 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { fetchUniLoginProfile, normalizeSocialType } = require('../../../lib/UniLoginClient')
+const {
+    getBindingByIdentity,
+    getUserSocialBindings,
+    insertSocialBinding,
+            updateSocialBindingProfile,
+    syncLegacySocialMirror,
+    toSocialBindingSummary
+} = require('../../../lib/UserSocialBinding')
+
+class BindSocial extends API {
+    constructor() {
+        super()
+
+        this.setPath('/UniLogin/BindSocial')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, type, code } = req.body
+        type = normalizeSocialType(type)
+
+        if ([uuid, session, code].some(value => value === '' || value === null || value === undefined))
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+
+        if (!type)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方账号类型'
+            })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+
+        try {
+            const { social_uid, nickname, faceimg } = await fetchUniLoginProfile(type, code)
+            const identityBinding = await getBindingByIdentity(type, social_uid)
+
+            if (identityBinding && identityBinding.user_uuid !== uuid)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '该第三方账号已绑定其他用户'
+                })
+
+            const bindings = await getUserSocialBindings(uuid)
+            if (bindings.some(binding => binding.social_type === type))
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: `当前账号已绑定${type === 'qq' ? 'QQ' : '微信'}`
+                })
+
+            const result = await insertSocialBinding(uuid, type, social_uid, nickname, faceimg)
+            if (!result || result.affectedRows !== 1)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '绑定失败,请稍后再试'
+                })
+            await updateSocialBindingProfile(type, social_uid, nickname, faceimg)
+
+            await db.query(
+                'UPDATE users SET nickname = COALESCE(NULLIF(nickname, ""), ?), avatar = COALESCE(NULLIF(avatar, ""), ?) WHERE uuid = ?',
+                [nickname, faceimg, uuid]
+            )
+            await syncLegacySocialMirror(uuid, type)
+
+            const newBindings = await getUserSocialBindings(uuid)
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    socialBindings: toSocialBindingSummary(newBindings),
+                    boundTypes: newBindings.map(binding => binding.social_type)
+                }
+            })
+        } catch (error) {
+            this.logger.error(`绑定第三方账号失败!${error.message || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '绑定失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.BindSocial = BindSocial

+ 27 - 3
apis/User/uniLogin/GetLoginUrl.js

@@ -3,6 +3,7 @@ const axios = require('axios')
 const config = require('../../../config.json')
 const { BaseStdResponse } = require("../../../BaseStdResponse");
 const https = require("https")
+const { normalizeSocialType } = require('../../../lib/UniLoginClient')
 
 class GetLoginUrl extends API {
     constructor() {
@@ -13,13 +14,36 @@ class GetLoginUrl extends API {
     }
 
     async onRequest(req, res) {
-        let { type, device } = req.query
+        let { type, device, action, from, mode } = req.query
+        type = normalizeSocialType(type)
+
+        if (!type)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方登录类型'
+            })
 
         const uniConfig = config.unilogin
 
-        let url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type || 'qq'}&redirect_uri=${encodeURIComponent(uniConfig.return_url)}`
+        const appendQuery = (redirectUrl) => {
+            const params = []
+            if (mode && !redirectUrl.includes('mode='))
+                params.push(`mode=${encodeURIComponent(mode)}`)
+            if (action)
+                params.push(`action=${encodeURIComponent(action)}`)
+            if (from)
+                params.push(`from=${encodeURIComponent(from)}`)
+
+            if (params.length === 0)
+                return redirectUrl
+
+            return `${redirectUrl}${redirectUrl.includes('?') ? '&' : '?'}${params.join('&')}`
+        }
+
+        let redirectUri = appendQuery(uniConfig.return_url)
+        let url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type}&redirect_uri=${encodeURIComponent(redirectUri)}`
         if (device && device === 'uniapp')
-            url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type || 'qq'}&redirect_uri=${encodeURIComponent(uniConfig.uni_return_url)}`
+            url = `${uniConfig.url}/connect.php?act=login&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type}&redirect_uri=${encodeURIComponent(appendQuery(uniConfig.uni_return_url))}`
 
         try {
             const r = await axios.get(url, {

+ 60 - 46
apis/User/uniLogin/Login.js

@@ -1,11 +1,16 @@
 const API = require("../../../lib/API")
-const axios = require('axios')
-const https = require('https')
 const { v4: uuidv4 } = require('uuid')
 const db = require("../../../plugin/DataBase/db")
 const Redis = require('../../../plugin/DataBase/Redis')
-const config = require('../../../config.json')
 const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { fetchUniLoginProfile, normalizeSocialType } = require('../../../lib/UniLoginClient')
+const {
+    getBindingByIdentity,
+    getLegacyUserByIdentity,
+    insertSocialBinding,
+    updateSocialBindingProfile,
+    syncLegacySocialMirror
+} = require('../../../lib/UserSocialBinding')
 
 class Login extends API {
     constructor() {
@@ -17,49 +22,45 @@ class Login extends API {
 
     async onRequest(req, res) {
         let { type, code } = req.body
+        type = normalizeSocialType(type)
 
         if ([code].some(value => value === '' || value === null || value === undefined))
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
             })
 
-        const uniConfig = config.unilogin
-
-        let url = `${uniConfig.url}/connect.php?act=callback&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${type || 'qq'}&code=${code}`
-        try {
-            const agent = new https.Agent({
-                rejectUnauthorized: false
-            })
-
-            const r = await axios.get(url, {
-                httpsAgent: agent,
-                proxy: false
+        if (!type)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方登录类型'
             })
 
-            if (!r || r.data?.code !== 0) {
-                this.logger.error(`获取用户信息失败!${r.data?.msg || 'api接口错误'}`)
-                return res.json({
-                    ...BaseStdResponse.ERR,
-                    msg: '获取用户信息失败!'
-                })
-            }
-
-            let { social_uid, nickname, faceimg, ip } = r.data
+        try {
+            let { social_uid, nickname, faceimg, ip } = await fetchUniLoginProfile(type, code)
             const session = uuidv4()
             const time = new Date().getTime()
 
-            let selectSql = 'SELECT uuid, username, permission FROM users WHERE social_uid = ? AND social_type = ?'
-            let selectRows = await db.query(selectSql, [social_uid, type || 'qq'])
-
-            let uuid, username, permission
+            let binding = await getBindingByIdentity(type, social_uid)
+            let uuid = binding?.user_uuid
+            let selectRows = []
+
+            if (uuid) {
+                selectRows = await db.query('SELECT * FROM users WHERE uuid = ? LIMIT 1', [uuid])
+            } else {
+                const legacyUser = await getLegacyUserByIdentity(type, social_uid)
+                if (legacyUser?.uuid) {
+                    uuid = legacyUser.uuid
+                    await insertSocialBinding(uuid, type, social_uid, nickname, faceimg)
+                    selectRows = await db.query('SELECT * FROM users WHERE uuid = ? LIMIT 1', [uuid])
+                }
+            }
 
-            // 用户不存在 执行注册操作
-            if (selectRows.length == 0) {
+            if (!uuid) {
                 uuid = uuidv4()
-                username = `用户${uuid.slice(0, 8)}`
+                const username = `用户${uuid.slice(0, 8)}`
 
                 let regSql = 'INSERT INTO users (uuid, username, registTime, social_uid, social_type, nickname, avatar, email) VALUES (?,?,?,?,?,?,?,?) '
-                let regRows = await db.query(regSql, [uuid, username, time, social_uid, type || 'qq', nickname, faceimg, '未设置'])
+                let regRows = await db.query(regSql, [uuid, username, time, social_uid, type, nickname, faceimg, '未设置'])
                 if (!regRows || regRows.affectedRows !== 1) {
                     this.logger.error(`聚合登录用户注册失败!数据库错误`)
                     return res.json({
@@ -67,40 +68,53 @@ class Login extends API {
                         msg: '用户注册失败!'
                     })
                 }
+
+                await insertSocialBinding(uuid, type, social_uid, nickname, faceimg)
+                selectRows = await db.query('SELECT * FROM users WHERE uuid = ? LIMIT 1', [uuid])
             }
-            else {
-                uuid = selectRows[0].uuid
-                username = selectRows[0].username
-                permission = selectRows[0].permission
-            }
+
+            if (!selectRows || selectRows.length === 0)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '用户登录失败!请稍后再试'
+                })
+
+            const user = selectRows[0]
 
             await Redis.set(`userSession:${uuid}`, session, {
                 EX: 2592000
             })
 
+            await db.query(
+                'UPDATE users SET lastTime = ?, avatar = ?, nickname = ? WHERE uuid = ?',
+                [time, faceimg, nickname, uuid]
+            )
+            await updateSocialBindingProfile(type, social_uid, nickname, faceimg)
+            await syncLegacySocialMirror(uuid, type)
+
             res.json({
                 ...BaseStdResponse.OK,
                 data: {
                     uuid,
-                    username,
+                    username: user.username,
                     session,
                     nickname,
-                    type: type || 'qq',
-                    roles: permission || [],
-                    avatar: faceimg,
+                    type,
+                    roles: user.permission || [],
+                    vip: user.vip,
+                    ic_count: user.ic_count,
+                    lepao_count: user.lepao_count,
+                    crouse_count: user.crouse_count,
+                    avatar: faceimg || user.avatar,
+                    email: user.email
                 }
             })
 
             // 增加登录记录
             try {
-                if (selectRows.length !== 0) {
-                    let updateSql = 'UPDATE users SET lastTime = ?, avatar = ?, nickname = ? WHERE social_uid = ? AND social_type = ?'
-                    await db.query(updateSql, [time, faceimg, nickname, social_uid, type || 'qq'])
-                }
-
                 const userAgent = req.headers['user-agent']
                 let insertSql = 'INSERT INTO login_history (uuid, time, deviceInfo, type, ip) VALUES (?, ?, ?, ?, ?)'
-                await db.query(insertSql, [uuid, time, { 'ua': userAgent }, type || 'qq', ip])
+                await db.query(insertSql, [uuid, time, { 'ua': userAgent }, type, ip])
             } catch (error) {
                 this.logger.error(`写入登录记录失败!${error}`)
             }

+ 89 - 0
apis/User/uniLogin/UnbindSocial.js

@@ -0,0 +1,89 @@
+const API = require("../../../lib/API")
+const db = require("../../../plugin/DataBase/db")
+const AccessControl = require("../../../lib/AccessControl")
+const { BaseStdResponse } = require("../../../BaseStdResponse")
+const { normalizeSocialType } = require('../../../lib/UniLoginClient')
+const {
+    getUserSocialBindings,
+    removeSocialBinding,
+    syncLegacySocialMirror,
+    toSocialBindingSummary
+} = require('../../../lib/UserSocialBinding')
+
+class UnbindSocial extends API {
+    constructor() {
+        super()
+
+        this.setPath('/UniLogin/UnbindSocial')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        let { uuid, session, social_type } = req.body
+        social_type = normalizeSocialType(social_type)
+
+        if ([uuid, session].some(value => value === '' || value === null || value === undefined))
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER
+            })
+
+        if (!social_type)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '不支持的第三方账号类型'
+            })
+
+        if (!await AccessControl.checkSession(uuid, session))
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED
+            })
+
+        try {
+            const bindings = await getUserSocialBindings(uuid)
+            if (!bindings.some(binding => binding.social_type === social_type))
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '当前账号未绑定该第三方账号'
+                })
+
+            const userRows = await db.query('SELECT password FROM users WHERE uuid = ? LIMIT 1', [uuid])
+            if (!userRows || userRows.length === 0)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '用户不存在'
+                })
+
+            if (bindings.length <= 1 && !userRows[0].password)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '请先设置登录密码或绑定其他第三方账号后再解绑'
+                })
+
+            const result = await removeSocialBinding(uuid, social_type)
+            if (!result || result.affectedRows !== 1)
+                return res.json({
+                    ...BaseStdResponse.ERR,
+                    msg: '解绑失败,请稍后再试'
+                })
+
+            await syncLegacySocialMirror(uuid)
+
+            const newBindings = await getUserSocialBindings(uuid)
+            return res.json({
+                ...BaseStdResponse.OK,
+                data: {
+                    socialBindings: toSocialBindingSummary(newBindings),
+                    boundTypes: newBindings.map(binding => binding.social_type)
+                }
+            })
+        } catch (error) {
+            this.logger.error(`解绑第三方账号失败!${error.message || error}`)
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '解绑失败,请稍后再试'
+            })
+        }
+    }
+}
+
+module.exports.UnbindSocial = UnbindSocial

+ 40 - 0
lib/UniLoginClient.js

@@ -0,0 +1,40 @@
+const axios = require('axios')
+const https = require('https')
+const config = require('../config.json')
+
+const VALID_SOCIAL_TYPES = ['qq', 'wx']
+
+function normalizeSocialType(type) {
+    const socialType = type || 'qq'
+    return VALID_SOCIAL_TYPES.includes(socialType) ? socialType : null
+}
+
+async function fetchUniLoginProfile(type, code) {
+    const socialType = normalizeSocialType(type)
+    if (!socialType)
+        throw new Error('不支持的第三方登录类型')
+
+    const uniConfig = config.unilogin
+    const url = `${uniConfig.url}/connect.php?act=callback&appid=${uniConfig.appid}&appkey=${uniConfig.appkey}&type=${socialType}&code=${code}`
+
+    const r = await axios.get(url, {
+        httpsAgent: new https.Agent({
+            rejectUnauthorized: false
+        }),
+        proxy: false
+    })
+
+    if (!r || r.data?.code !== 0)
+        throw new Error(r.data?.msg || 'api接口错误')
+
+    return {
+        ...r.data,
+        social_type: socialType
+    }
+}
+
+module.exports = {
+    VALID_SOCIAL_TYPES,
+    normalizeSocialType,
+    fetchUniLoginProfile
+}

+ 114 - 0
lib/UserSocialBinding.js

@@ -0,0 +1,114 @@
+const db = require('../plugin/DataBase/db')
+const { normalizeSocialType } = require('./UniLoginClient')
+
+async function getBindingByIdentity(socialType, socialUid) {
+    const type = normalizeSocialType(socialType)
+    if (!type || !socialUid)
+        return null
+
+    const rows = await db.query(
+        'SELECT user_uuid, social_type, social_uid, social_nickname, social_avatar FROM user_social_bindings WHERE social_type = ? AND social_uid = ? LIMIT 1',
+        [type, socialUid]
+    )
+
+    return rows?.[0] || null
+}
+
+async function getLegacyUserByIdentity(socialType, socialUid) {
+    const type = normalizeSocialType(socialType)
+    if (!type || !socialUid)
+        return null
+
+    const rows = await db.query(
+        'SELECT uuid, username, permission, avatar, nickname FROM users WHERE social_uid = ? AND social_type = ? LIMIT 1',
+        [socialUid, type]
+    )
+
+    return rows?.[0] || null
+}
+
+async function getUserSocialBindings(userUuid) {
+    const rows = await db.query(
+        'SELECT social_type, social_uid, social_nickname, social_avatar FROM user_social_bindings WHERE user_uuid = ? ORDER BY FIELD(social_type, "qq", "wx")',
+        [userUuid]
+    )
+
+    return rows || []
+}
+
+async function insertSocialBinding(userUuid, socialType, socialUid, socialNickname = null, socialAvatar = null) {
+    const type = normalizeSocialType(socialType)
+    if (!type || !socialUid)
+        return null
+
+    const now = new Date().getTime()
+    return await db.query(
+        'INSERT IGNORE INTO user_social_bindings (user_uuid, social_type, social_uid, social_nickname, social_avatar, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
+        [userUuid, type, socialUid, socialNickname, socialAvatar, now, now]
+    )
+}
+
+async function updateSocialBindingProfile(socialType, socialUid, socialNickname = null, socialAvatar = null) {
+    const type = normalizeSocialType(socialType)
+    if (!type || !socialUid)
+        return null
+
+    const now = new Date().getTime()
+    return await db.query(
+        'UPDATE user_social_bindings SET social_nickname = ?, social_avatar = ?, updated_at = ? WHERE social_type = ? AND social_uid = ?',
+        [socialNickname, socialAvatar, now, type, socialUid]
+    )
+}
+
+async function syncLegacySocialMirror(userUuid, preferredSocialType = null) {
+    const bindings = await getUserSocialBindings(userUuid)
+    const preferredType = normalizeSocialType(preferredSocialType)
+    const binding = bindings.find(item => item.social_type === preferredType) || bindings[0]
+
+    if (!binding) {
+        return await db.query(
+            'UPDATE users SET social_uid = NULL, social_type = NULL WHERE uuid = ?',
+            [userUuid]
+        )
+    }
+
+    return await db.query(
+        'UPDATE users SET social_uid = ?, social_type = ? WHERE uuid = ?',
+        [binding.social_uid, binding.social_type, userUuid]
+    )
+}
+
+async function removeSocialBinding(userUuid, socialType) {
+    const type = normalizeSocialType(socialType)
+    if (!type)
+        return null
+
+    return await db.query(
+        'DELETE FROM user_social_bindings WHERE user_uuid = ? AND social_type = ?',
+        [userUuid, type]
+    )
+}
+
+function toSocialBindingSummary(bindings = []) {
+    return ['qq', 'wx'].map(type => {
+        const binding = bindings.find(item => item.social_type === type)
+        return {
+            type,
+            bound: !!binding,
+            social_uid: binding?.social_uid || '',
+            nickname: binding?.social_nickname || '',
+            avatar: binding?.social_avatar || ''
+        }
+    })
+}
+
+module.exports = {
+    getBindingByIdentity,
+    getLegacyUserByIdentity,
+    getUserSocialBindings,
+    insertSocialBinding,
+    updateSocialBindingProfile,
+    syncLegacySocialMirror,
+    removeSocialBinding,
+    toSocialBindingSummary
+}