私有部署supabase完整手册-4 自定义Oauth实现抖音授权登录

背景

Supabase是一个开源的Firebase替代品,它提供了实时数据库、身份验证、存储等功能。它基于PostgreSQL,允许开发者构建可扩展的应用程序。 如果你不清楚如何部署,请参考我之前的文章<私有部署supabase完整手册-1>

尽管 Supabase 大大简化了后端开发的复杂性,为开发者提供了一个强有力的一站式解决方案,但国内用户在使用过程中可能会遇到一些挑战。由于官方服务主要面向国际市场,并未针对国内网络环境进行优化,因此国内用户可能需要考虑自托管部署来确保服务的稳定性和访问速度。

虽然 Supabase 内置了 GitHub、Google 等第三方登录方式,但对于国内用户来说,这些选项未必适用。若想集成如抖音、微信等更符合本地需求的登录方式,则需要自行探索实现路径。遗憾的是,关于如何在国内环境下充分利用 Supabase 的资料相对稀缺,尤其是涉及到与本地社交平台集成的具体方案。

怀着分享知识、促进社区共同成长的精神,我决定公开自己成功实现抖音和微信集成到 Supabase 的详细步骤。希望这份指南能够帮助更多开发者克服类似的挑战,同时也期待社区内的朋友们能提出宝贵的反馈和建议,一起推动 Supabase 在中文互联网世界的应用与发展。

备选方案

方案一,直接扩展auth模块,增加抖音provider, 认证模块是go语言编写,我不熟悉,另外修改代码后不便于以后升级.
方案二,通过边函数实现接入. 利用抖音提供的客户端sdk,配合边函数,能够很快接入,避免修改源码的不利因素.

本文采用第二种方案,使用边函数实现抖音用户登录.

开始集成

步骤 1:注册抖音开放平台

https://developer.open-douyin.com/ 注册并完成企业认证后,申请移动应用.之后在应用基本信息中会得到
Client Key 和 client Secret 两个关键信息.如下图:
在这里插入图片描述

步骤2:配置supabase环境

打开自托管目录 编辑 .env文件,增加如下内容.将Client Key 和 client Secret 替换为你自己在抖音申请的内容.

# 抖音开放平台参数 

# 抖音 OAuth 配置(关键修正)
GOTRUE_EXTERNAL_DOUYIN_ENABLED=true
GOTRUE_EXTERNAL_DOUYIN_CLIENT_ID=xxxxxxxxxx
GOTRUE_EXTERNAL_DOUYIN_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GOTRUE_EXTERNAL_DOUYIN_REDIRECT_URI=https://ctaias.com/auth/v1/callback

接着编辑 docker-compose.yml 文件, 和.env在相同目录下. 配置边函数环境变量.

services:
  functions:
  	environment:
  		#增加如下内容
  		#增加抖音登录参数
      GOTRUE_EXTERNAL_DOUYIN_ENABLED: ${GOTRUE_EXTERNAL_DOUYIN_ENABLED}
      GOTRUE_EXTERNAL_DOUYIN_CLIENT_ID: ${GOTRUE_EXTERNAL_DOUYIN_CLIENT_ID}
      GOTRUE_EXTERNAL_DOUYIN_SECRET: ${GOTRUE_EXTERNAL_DOUYIN_SECRET}
      GOTRUE_EXTERNAL_DOUYIN_REDIRECT_URI: ${GOTRUE_EXTERNAL_DOUYIN_REDIRECT_URI}

步骤三:创建用户资料辅助表

打开在 Supabase web控制台,执行如下脚本, 将创建一个用户资料表,和auth.users表建立外键关联.

进入SQL Editor->SQL Query, 执行.

-- ----------------------------
-- Table structure for UserProfile
-- ----------------------------
DROP TABLE IF EXISTS "public"."UserProfile";
CREATE TABLE "public"."UserProfile" (
  "id" int8 NOT NULL GENERATED BY DEFAULT AS IDENTITY (
INCREMENT 1
MINVALUE  1
MAXVALUE 9223372036854775807
START 1
CACHE 1
),
  "created_at" timestamptz(6) NOT NULL DEFAULT now(),
  "displayName" varchar COLLATE "pg_catalog"."default",
  "photo" varchar COLLATE "pg_catalog"."default",
  "user_id" uuid,
  "open_id" text COLLATE "pg_catalog"."default",
  "temp_pwd" text COLLATE "pg_catalog"."default"
)
;
COMMENT ON COLUMN "public"."UserProfile"."user_id" IS '外键关联auth.user表id';
COMMENT ON COLUMN "public"."UserProfile"."open_id" IS '第三方登录id';
COMMENT ON TABLE "public"."UserProfile" IS '用户信息表';

-- ----------------------------
-- Indexes structure for table UserProfile
-- ----------------------------
CREATE INDEX "UserProfile_open_id_idx" ON "public"."UserProfile" USING btree (
  "open_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
);

-- ----------------------------
-- Primary Key structure for table UserProfile
-- ----------------------------
ALTER TABLE "public"."UserProfile" ADD CONSTRAINT "UserInfo_pkey" PRIMARY KEY ("id");

-- ----------------------------
-- Foreign Keys structure for table UserProfile
-- ----------------------------
ALTER TABLE "public"."UserProfile" ADD CONSTRAINT "UserProfile_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users" ("id") ON DELETE CASCADE ON UPDATE NO ACTION;

稍后我们在认证边函中读取到用户的抖音昵称,头像等资料将保存在这个表中.

步骤四:创建oauth边函数,实现抖音/微信用户认证

边函数的主要的目的及操作过程如下:
1,将sdk客户端获取的authcode, 调用抖音接口读取userinfo信息;
2,根据open_id判断本地是否已存在? 不存在则先创建用户;
3,根据open_id读取本地账户信息, 在边函数中完成身份认证;
4,将认证后的session返回安卓客户;

客户端拿到session后,导入本地,完成登录过程.

边函数如下:如果你不清楚如何创建边函数,请首先参考我上一篇文章<私有部署supabase完整手册-3 边函数(Edge Functions)开发/部署/测试>

authProviders.ts 读取抖音接口的异步函数,稍后在主函数中使用

// authProviders.ts

/**
 * 根据授权 code 获取第三方平台的用户信息
 * @param provider 第三方平台标识,如 'douyin' 或 'wechat'
 * @param code 授权码
 * @returns 用户信息对象
 */
export async function getUserInfoFromProvider(provider: 'douyin' | 'wechat', code: string) {
    const CLIENT_ID = Deno.env.get(`GOTRUE_EXTERNAL_${provider.toUpperCase()}_CLIENT_ID`);
    const CLIENT_SECRET = Deno.env.get(`GOTRUE_EXTERNAL_${provider.toUpperCase()}_SECRET`);
    const REDIRECT_URI = encodeURIComponent(Deno.env.get(`GOTRUE_EXTERNAL_${provider.toUpperCase()}_REDIRECT_URI`) || '');

    if (!CLIENT_ID || !CLIENT_SECRET) {
        throw new Error(`Missing client credentials for provider: ${provider}`);
    }

    let accessToken: string;
    let open_id:string; 

    switch (provider) {
        case 'douyin':
            // 抖音获取 access_token
            const tokenResponse = await fetch(
                `https://open.douyin.com/oauth/access_token?client_key=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&code=${code}&grant_type=authorization_code&redirect_uri=${REDIRECT_URI}`
            );

            if (!tokenResponse.ok) {
                throw new Error(`Failed to get access token from Douyin: ${tokenResponse.statusText}`);
            }

            const tokenData = await tokenResponse.json();
            accessToken = tokenData.data.access_token;
            open_id = tokenData.data.open_id
            
            //console.log("getUserInfoFromProvider", "tokenResponse=" + JSON.stringify(tokenData))
            //console.log("---------------------------------------------------------------");
            
            //console.log("getUserInfoFromProvider", "accessToken=" + accessToken)
            //console.log("---------------------------------------------------------------");

            // 获取用户信息
            const userInfoResponse = await fetch(
                `https://open.douyin.com/oauth/userinfo/?access_token=${accessToken}&open_id=${open_id}`
            );

            if (!userInfoResponse.ok) {
                throw new Error(`Failed to get user info from Douyin: ${userInfoResponse.statusText}`);
            }

            let {data:userInfo, message} = await userInfoResponse.json();
            
            if(message != "success"){
                throw new Error(`Failed to get user info from Douyin: ${message}`);
            }
            
            //console.log("getUserInfoFromProvider", "userInfo=" + JSON.stringify(userInfo, null, 2));
            //console.log("---------------------------------------------------------------");

            return {
                open_id: userInfo.open_id,
                nickname: userInfo.nickname,
                avatarUrl: userInfo.avatar_larger,
                rawUserInfo: userInfo,
            };

        case 'wechat':
            // 微信获取 access_token 示例(可根据实际需求补充)
            const wechatTokenResponse = await fetch(
                `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${CLIENT_ID}&secret=${CLIENT_SECRET}&code=${code}&grant_type=authorization_code`
            );

            if (!wechatTokenResponse.ok) {
                throw new Error(`Failed to get access token from WeChat: ${wechatTokenResponse.statusText}`);
            }

            const wechatTokenData = await wechatTokenResponse.json();
            accessToken = wechatTokenData.access_token;
            const openId = wechatTokenData.openid;

            // 获取用户信息
            const wechatUserInfoResponse = await fetch(
                `https://api.weixin.qq.com/sns/userinfo?access_token=${accessToken}&openid=${openId}`
            );

            if (!wechatUserInfoResponse.ok) {
                throw new Error(`Failed to get user info from WeChat: ${wechatUserInfoResponse.statusText}`);
            }

            userInfo = await wechatUserInfoResponse.json();

            return {
                open_id: userInfo.openid,
                nickname: userInfo.nickname,
                avatarUrl: userInfo.headimgurl,
                rawUserInfo: userInfo,
            };

        default:
            throw new Error(`Unsupported provider: ${provider}`);
    }
}

index.ts 主函数

// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts" 

import { createClient } from 'jsr:@supabase/supabase-js@2'
//import axios from "https://deno.land/x/axios/mod.ts";
import { getUserInfoFromProvider } from './authProviders.ts'; // 导入我们刚刚创建的文件

console.log("Hello from Functions!")

// 配置抖音应用的相关环境变量
const TIKTOK_CLIENT_ID = Deno.env.get('GOTRUE_EXTERNAL_DOUYIN_CLIENT_ID');
const TIKTOK_CLIENT_SECRET = Deno.env.get('GOTRUE_EXTERNAL_DOUYIN_SECRET');
const REDIRECT_URI = encodeURIComponent(Deno.env.get('GOTRUE_EXTERNAL_DOUYIN_REDIRECT_URI')); // 例如:'your-redirect-uri'

Deno.serve(async (req) => {
    const { code, provider } = await req.json();
    console.log("Hello from Functions!code=" + code + ", provider=" + provider)

    if (!code) {
        return new Response(JSON.stringify({ error: 'Missing code parameter' }), { status: 400 });
    }
    // 检查 provider 是否为有效值
    if (!provider || !['douyin', 'wechat', 'facebook'].includes(provider)) {
        return new Response(JSON.stringify({ error: 'Invalid or missing provider' }), { 
            status: 400,
            headers: { 'Content-Type': 'application/json' }
        });
    }

    try {
        // Step 1: Exchange the code for an access token
        //const tokenResponse = await axios.post(`https://open.douyin.com/oauth/access_token?client_key=${TIKTOK_CLIENT_ID}&client_secret=${TIKTOK_CLIENT_SECRET}&code=${code}&grant_type=authorization_code&redirect_uri=${REDIRECT_URI}`);
        //const accessToken = tokenResponse.data.access_token;

        // Step 2: Use the access token to get user info
        //const userInfoResponse = await axios.get(`https://open.douyin.com/oauth/userinfo/?access_token=${accessToken}`);
        //const userInfo = userInfoResponse.data;

        // 使用 getUserInfoFromProvider 函数
        const userInfo = await getUserInfoFromProvider(provider, code); 

        //console.log("User info:", JSON.stringify(userInfo, null, 2));
        //console.log("---------------------------------------------------------------");

        // Step 3: Check if the user exists in Supabase
        const supabaseAdmin = createClient(
            Deno.env.get('SUPABASE_URL'),
            Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') // 使用服务角色密钥以获得高权限操作
        );  
        
        let { data: allUsers, status } = await supabaseAdmin
          .from('UserProfile')
          .select('*')
          .eq('open_id', userInfo.open_id); 
        
        //console.log("All users:", JSON.stringify(allUsers, null, 2) + ". status:" + status); 
        
        let email = `${userInfo.open_id}@${provider}`; // 如果没有email,则使用open_id构造一个
        let password = Math.random().toString(36).slice(-8); // 生成随机密码,但不返回给客户端

        let retSession;
        if (allUsers.length === 0) {
            // 如果用户不存在,则创建新用户
            let { data, error } = await supabaseAdmin.auth.admin.createUser({
                email: email,
                password: password, //
                user_metadata: { open_id: userInfo.open_id },
                email_confirm:true
            });

            if (error) {
                console.error("Error creating user:", error);
                return new Response(JSON.stringify({ error: 'Failed to create user' + JSON.stringify(data, null, 2) }), { status: 500 });
            }
            console.log("oauth", "创建新用户成功(Users). email:" + email)
            
            //插入UserProfile 附加表
            let { status } = await supabaseAdmin.from('UserProfile')
                .insert({ open_id: userInfo.open_id, displayName: userInfo.nickname, photo:userInfo.avatarUrl, user_id:data.user.id, temp_pwd:password });
            
            //console.log("*****email=" + email + ",pwd:" + password + ", data=" + JSON.stringify(data));
            console.log("oauth", "创建新用户配置成功(UserProfile). status:" + status + ", email:" + email)

            // 登录新创建的用户 
            const { data: { session }, error:signInError } = await supabaseAdmin.auth.signInWithPassword({
                email: email,
                password: password // 注意:这里需要正确地处理密码逻辑
            });

            if (signInError) {
                console.error("Error signing in user:", signInError);
                return new Response(JSON.stringify({ error: 'Failed to sign in user.'}), { status: 500 });
            }
            console.log("oauth", "新用户登录成功, return session. email:" + email)
            
            retSession = session
        } else {
            console.log("oauth", "用户已存在,使用已有数据登录." + JSON.stringify(allUsers, null, 2))
            
            //查询用户
            const { data, error } = await supabaseAdmin.auth.admin.getUserById(allUsers[0].user_id)
            
            console.log("-----------------------------------------------")
            console.log("oauth", "查询用户成功. email=" + data.user.email)
            
            // 对于已存在的用户,直接登录 
            const { data: { session }, error:signInError } = await supabaseAdmin.auth.signInWithPassword({
                email: data.user.email,
                password: allUsers[0].temp_pwd // 同样需要注意实际如何获取正确的密码字段
            });

            if (signInError) {
                console.error("Error signing in existing user:", signInError);
                return new Response(JSON.stringify({ error: 'Failed to sign in existing user' + signInError }), { status: 500 });
            }
            console.log("oauth", "用户登录成功.返回session. email=" + data.user.email)
            
            retSession = session
        }
        
        //console.log(`email=${email}, pwd:${password}, session:${JSON.stringify(retSession, null, 2)}`);
        
        return new Response(JSON.stringify(retSession), { 
            headers: { "Content-Type": "application/json" },
            status: 200 
        });

    } catch (error) {
        console.error(error);
        return new Response(JSON.stringify({ error: 'Error during authentication' }), { 
            status: 500 
        });
    }
});

步骤五: 抖音客户端集成

参考抖音官方文档: https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/mobile-app/permission/android/permission-develop-guide

完成抖音官方sdk集成后,你将会在回调函数中获取 access_token, 拿到这个token后调用我们上文创建的oauth边函数,将token传递后将得到安卓客户端session.

步骤六: 安卓客户端oauth边函数调用及写入本地session完成登录

/**
     * 登录授权
     * @param provider 登录授权提供商
     * @param code 登录授权码
     */
    override suspend fun oauth(provider:String, code:String): Boolean{
        return try {
            val ret = functions.invoke(function = "oauth", body=mapOf("provider" to provider, "code" to code))
            val data = ret.body<UserSession>()
            Log.d("UserRepositoryImpl", "oauthDouyin, data:  $data")

            //将服务端获取的session导入当前用户,立即生效.
            auth.importSession(data)
            auth.sessionManager.saveSession(data)

            true
        } catch (e: RestException) {
            if(BuildConfig.DEBUG)
                Log.e("UserRepositoryImpl", "oauthDouyin", e)
            false
        }catch (e: Exception){
            if(BuildConfig.DEBUG)
                Log.e("UserRepositoryImpl", "oauthDouyin", e)
            false
        }
    }

最终效果:

抖音用户登录效果

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值