小程序登录

收藏
我的收藏

简介

能力介绍

如果你的小程序是一个纯工具类的应用,不需要在服务端保存任何用户信息,其实完全没有必要接入登录功能。
但在大多数业务场景下,小程序需要获取用户的「唯一用户标识」,用于在服务端数据库中记录用户在小程序内的各种行为数据。当用户更换设备或重新登录后,依然可以从服务端重新拉取之前的数据。典型的应用场景有:订单、履约、积分、浏览记录。
对于不同的业务场景,我们建议使用不同的登录/注册方案。接下来,我们以程序员小 A 的视角来看一下不同场景适用的方案:
程序员小 A 决定创业,刚起步的时候,在抖音(含极速版)上开发了一个小程序,叫“天天记事”,用户可以在里面记录自己的日常生活琐事。出于区别不同用户信息的目的,小 A 需要对用户进行唯一标记,这个时候他就需要用到 open_id,并且需要查看「静默登录/注册」章节。
过了一段时间,小 A 的业务风生水起,DAU 已经破万,于是有老板来投资他,他在同一个公司主体下做了“天天团购”、“天天租车”、“天天旅行”等“天天系列”的小程序。为了能让同一个用户在不同的小程序之间共享积分,他需要使用 union_id,并且再次查看「静默登录/注册」章节。
再过了一段时间,小 A 得到了一大笔融资,开始开发自己的独立 App,名叫“天天 App”。为了方便用户注册&登录,他接入了抖音登录 SDK,这时候为了关联天天 App 中的用户和抖音小程序中的用户,他依然需要使用 union_id。
又过了一段时间,小 A 的“天天集团”开始进军「WX 小程序」和「ZFB 小程序」。这时他必须开发基于手机号的用户唯一标识,因此他需要查看「获取手机号」。
再往后,小 A 的业务走出了中国,走向了世界。但国外的用户不习惯用手机号注册,而倾向用邮箱、用户名等方式注册,这时候他需要查看「自定义注册」。

名词解释

名词
描述
open_id
同一个用户在同一个应用中的唯一标识。
union_id
同一个用户在同一个主体名下不同应用中的唯一标识。
code
用户访问小程序后,小程序获得的一个临时票据,代表该用户在该小程序内进行了登录。需要在这个票据过期前,将它传到服务端,通过服务端的接口换取密钥(session_key)。
session
登录状态,用户在某个小程序内登录成功的状态,随着时间的流逝,这个状态可能会过期,因此需要经常检查。
session_key
与 session 关联的密钥,用于解密服务端返回的加密数据。如果 session 被重置(过期),session_key 也会失效。
宿主
小程序的运行环境 App,例如:抖音、抖音极速版、今日头条。

视频教程

Demo 安装及使用

本 demo 由一个小程序前端工程和一个服务端工程组成,请先在本地启动服务端,再修改前端代码中的“服务端地址、端口”字段,才能正常运行。

前端

解压 zip 包,使用抖音开发者工具导入 front-end 文件夹即可。开发者可修改 front-end/utils/promisify.js 中的 BASE_URL 变量来变更开发者服务端的 URL,同时需要修改 front-end/project.config.json 中的 appid,使其与开发者服务端的 appid 对应。

开发者服务端

    Go-hertz 版本:
    a.保证安装 Go 运行环境,推荐 go1.18。
    b.解压zip包,运行 cd ./go 进入 go 工程。
    c.运行 go build -o hertz_demo
    d.运行 ./hertz_demo appId=ttXXXX secret=4XXXX hostPorts=localhost:8080
    e.具体参数参考 Readme 文件。
    Java-SpringBoot 版本:
    a.保证 Java 运行环境,推荐 Java1.8。
    b.解压 zip 包,运行 cd ./java 进入 Java 工程。
    c.运行 mvn package
    d.运行 java -jar target/showcase.jar --server.port=8081 -DappId=ttXXXX -Dsecret=08XXXXXX
    e.具体运行参数参考 Readme 文件。
    Python-Flask 版本:
    a.保证 Python3 运行环境,推荐 3.2 以上版本。
    b.解压 zip 包,cd ./python 进入 Python 工程。
    c.运行 python app.py appId=ttXXXX secret=08XXXX hostPorts=localhost:8080.
    d.具体运行参数参考 Readme 文件。
    Php-Codeignite 版本:
    a.保证 PHP 运行环境,推荐 php8.0+ 版本。
    b.解压 zip 包,cd ./php 进入 PHP 工程。
    c.运行 php spark serve --port 8081
    d.小程序配置参考..env 配置文件
    e.具体运行参数参考 Readme。
    NodeJs-Koa 版本:
    a.保证本地安装 NodeJs 运行环境,推荐 NodeJs 版本 v14.17.0。
    b.解压 zip 包,运行cd ./NodeJs进入nodejs项目,运行 npm install 安装依赖。
    c.等待依赖安装完毕后,运行 node index.js 或者 npm run server 启动服务。
    d.按照行提示填写:是否启用沙盒、小程序 AppId、小程序 AppSecret 和启动端口,服务已启动。

静默登录/注册

静默登录指“已登录抖音宿主”的用户,在访问小程序页面时,静默获取该用户的「唯一用户标识」,用于在后台进行新注册或匹配已注册用户。
抖音提供的「唯一用户标识」有两种,一种是 open_id,另一种是 union_id。大多数情况下,使用 open_id 即可。只有同一个开发者主体下多小程序,需要跨小程序唯一识别用户时,才使用 union_id。
open_id vs union_id:
注意事项:
    在抖音的某些场景中,有可能会出现用户访问小程序时,还没有登录抖音宿主账号的情况。如果你同时也在开发WX小程序,在使用 tt.login 时,不能直接照搬 wx.login 的逻辑。建议先将 tt.login options 属性中的 force 设为 false,如果在 success 回调中发现 isLogin 为 false,提示用户先登录抖音宿主账号,后续再进行 code 获取等流程。
    小程序的运行生命周期里,可能会用到两个 session。小程序用户在抖音上的登录 session,它由 tt.login 发起,用 tt.checkSession 来检查是否过期。小程序用户在开发者服务端登录的 session,它由开发者自己定义和实现(本 demo 中给出了一种实现方式)。由于每次调用 tt.login 都会导致服务端的 session_key 过期,所以我们建议在小程序整个冷启动生命周期内,最多只调用一次 tt.login。第一次调用 tt.login 后,应该将换取的 session_key 保存在服务端,并与“小程序用户在开发者服务端登录的 session 标识”关联。当需要再次使用 session_key 进行解密时,不要再调用 tt.login,而是将密文和“小程序用户在开发者服务端登录的 session 标识”传送至服务端,在服务端查找保存的 session_key。
    抖音登录 session 仅用于一次性获取用户在抖音服务端的信息,在获取到用户的抖音信息(如:open_id、union_id、手机号等)以后,应当将其保存到服务端的“用户表”中,并且生成相应的随机字符串,当作登录凭证进行下发。我们强烈建议不要将 open_id 或 union_id 明文下发到小程序前端用作登录凭证。如果需要在前端展示 open_id、union_id 或手机号,请将信息脱敏后下发,例如手机号:137****3749。
时序图:

前端

静默登录依赖小程序 API tt.login,把tt.loginsuccess 回调中返回的 code 传给开发者服务端,换取开发者服务端生成的 token。具体的前端流程如下:
    1.判断抖音宿主是否登录。已登录,下一步。未登录,则显示登录抖音主端的按钮,提示用户登录。
    2.登录抖音成功后,tt.login 会返回一个 code,前端携带 code 向开发者服务端请求。
    3.开发者服务端通过 code2session 换取 sessionKey、open_id 和 union_id,并生成与 open_id 对应的唯一 token。
    4.开发者服务端将 token 返回前端。
    5.前端将 token 缓存,下次直接携带 token 向开发者服务端请求数据。

代码示例

首先准备一些方便我们开发的代码封装。
文件front/utils/promisify.js:将小程序 API 封装成 Promise 形式,同时封装tt.reuqest,定义 baseURL 变量,代表开发者服务端地址。
// 开发者后端url,使用者可自行替换 export const BASE_URL = 'http://127.0.0.1:8000'; /** * @description: 将小程序api封装成Promise * @param {any} func js api,例如tt.login * @param {*} options 传入api的参数 * @return {*} */ export function promisify(func, options = {}) { return new Promise((resolve, reject) => { func({ ...options, success(res) { resolve(res); }, fail(err) { reject(err); }, }); }); } /** * @description: 封装tt.request方法 * @param {RequestData} options * @return {*} */ export async function request(options) { const urlRegexp = /(http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?/; const { url } = options; if (url && !urlRegexp.test(url)) { options.url = new URL(url, BASE_URL).href; } return await promisify(tt.request, options); }
文件front/utils/login.js:定义 LoginController 类,封装向开发者服务端的交互方法,同时记录登录态。
import { promisify, request } from './promisify'; // 是否登录开发者服务端 let isDeveloperServerLogin = false; // 是否登录抖音主端 let isDouyinLogin = false; class LoginController { _token = ''; _openid = ''; _unionid = ''; /** * @description: 登录函数,首先调用tt.login获取code,并向开发者服务端请求登录 * @param {*} force 当用户未登录抖音app时,是否强制调起抖音app登录界面 * @return {*} */ async login(force = true) { if (isDeveloperServerLogin) { return; } let loginData = null; try { // 是否强制调起抖音的登录窗口 loginData = await promisify(tt.login, { force }); isDouyinLogin = loginData.isLogin; } catch (err) { console.error(err); tt.showToast({ icon: 'fail', title: '开发者取消登录抖音', }); } if (isDouyinLogin && loginData) { try { // 与开发者服务端交互,进行登录 await this.loginToDeveloperServer(loginData.code); isDeveloperServerLogin = true; } catch (err) { console.error(err); tt.showToast({ title: '授权登录失败', icon: 'fail', }); } } return isDouyinLogin; } /** * @description: 请求开发者服务端,做code2session * @param {string} code tt.login返回的code * @return {*} */ async loginToDeveloperServer(code) { try { tt.showLoading({ title: '正在登录开发者服务端', }); const res = await request({ url: '/api/apps/login', method: 'post', data: { code, }, }); this._token = res.data.token; this._openid = res.data.openid; this._unionid = res.data.unionid; tt.setStorageSync('token', res.data.token); tt.setStorageSync('openid', res.data.openid); tt.setStorageSync('unionid', res.data.unionid); } catch (err) { console.log(err); } finally { tt.hideLoading({}); } } /** * @description: 登出开发者服务端 * @return {*} */ async logout() { await request({ url: '/api/apps/logout', method: 'post', data: { token: loginController.getToken(), }, }); isDeveloperServerLogin = false; tt.removeStorageSync('token'); tt.removeStorageSync('openid'); tt.removeStorageSync('unionid'); tt.removeStorageSync('phoneNumberLogin'); tt.removeStorageSync('customLogin'); } /** * @description: 返回是否登录抖音主端 * @return {*} */ isDouyinLogin() { return isDouyinLogin; } /** * @description: 是否登录开发者服务端 * @return {*} */ isDeveloperServerLogin() { return isDeveloperServerLogin; } /** * @description: 获取localStorage中的token * @return {string} */ getToken() { return this._token || tt.getStorageSync('token'); } // openid和unionid由开发者服务端下发,此处只是为了前端做展示; // 在实际情况中不建议开发者下发openid和unionid /** * @description: 获取localStorage中的openid * @return {string} */ getOpenid() { return this._openid || tt.getStorageSync('openid'); } /** * @description: 获取localStorage中的unionid * @return {string} */ getUnionid() { return this._unionid || tt.getStorageSync('unionid'); } } export const loginController = new LoginController();
前端代码示例:
import { loginController } from '../../utils'; Page({ data: { openid: '', unionid: '', // 抖音登录状态 isLogin: true, }, async onLoad() { // 判断用户是否登录抖音主端 const isDouyinLogin = await loginController.login(); // setData设置抖音登录状态 this.setData({ isLogin: isDouyinLogin, }); // 如果用户已登录开发者服务端,则在缓存中获取openid和unionid if (loginController.isDeveloperServerLogin()) { this.setData({ openid: loginController.getOpenid(), unionid: loginController.getUnionid(), }); } }, /** * @description: 点击事件,若用户没登录抖音主端,则强制调取登录界面,并向开发者服务端进行登录 * @return {*} */ async login() { const isDouyinLogin = await loginController.login(); this.setData({ openid: loginController.getOpenid(), unionid: loginController.getUnionid(), isLogin: isDouyinLogin, }); }, });
最终效果:
详细代码请参考front-end/pages/default-login/default-login.js文件。

前置登录 vs 后置登录

    使用 tt.login 静默获取 open_id 的前提是——用户使用抖音时已经登录了抖音账号。在某些情况下(例如:端外扫码,唤起抖音客户端跳转小程序)可能会有未登录抖音账号的用户访问到小程序。平台建议所有小程序都有“游客模式”,让用户先体验小程序,在必须要进行注册/登录才调用,因此不建议在小程序「落地页面」上使用静默登录/注册,而是在二级页面上使用。
    如果你的小程序必须要在「落地页面」上进行登录,例如:直播间挂载的就是一个商品详情页,在该页面上有一个 <pay-button>。那我们提供两个方案供你参考:
    前置登录:正如我们的 demo 中所展示的,先使用 tt.login,把 force 参数设置为 false。在 success 返回中,检查 isLogin 是否为 true。如果 isLogin 为 false,则先不展示 <pay-button>,而是展示一个“登录”按钮,引导用户先登录,待登录成功以后,再显示 <pay-button>。
    后置登录:直接展示 <pay-button> 让用户点击,如果后续流程报错,再调用 tt.login。不过用户登录以后,需要再点击一次 <pay-button>。

开发者服务端

Go

func Login(context context.Context, req *model.LoginRequest) *model.LoginResponse { code2SessionReq := &code2SessionRequest{ AppId: conf.Appid, Secret: conf.Secret, Code: req.Code, ACode: "", } code2SessionResp, err := code2Session(context, code2SessionReq) if err != nil { response := &model.LoginResponse{ ErrCode: -1, ErrMsg: err.Error(), Token: "", } return response } if code2SessionResp.ErrNo != 0 { response := &model.LoginResponse{ ErrCode: -1, ErrMsg: code2SessionResp.ErrTips, Token: "", } return response } token := store.SetCache(context, "", code2SessionResp.Data.Openid, code2SessionResp.Data.UnionId, "", code2SessionResp.Data.SessionKey) response := &model.LoginResponse{ ErrCode: 0, ErrMsg: "success", Token: token, Openid: masker.ID(code2SessionResp.Data.Openid), Unionid: masker.ID(code2SessionResp.Data.UnionId), } return response } const ( domain = "developer.toutiao.com" sandBoxDomain = "open-sandbox.douyin.com" code2SessionPath = "/api/apps/v2/jscode2session" ) type code2SessionRequest struct { AppId string `json:"appid"` Secret string `json:"secret"` Code string `json:"code"` ACode string `json:"anonymous_code"` } type Code2SessionResponseData struct { SessionKey string `json:"session_key"` Openid string `json:"openid"` AOpenId string `json:"anonymous_openid"` UnionId string `json:"unionid"` DOpenid string `json:"dopenid"` } type Code2SessionResponse struct { ErrNo int64 `json:"err_no"` ErrTips string `json:"err_tips"` Data Code2SessionResponseData `json:"data"` } func code2Session(ctx context.Context, req *code2SessionRequest) (*Code2SessionResponse, error) { reqBodyByte, _ := json.Marshal(req) reqDomain := domain // 沙盒切换 if conf.IsSandBox == "1" { reqDomain = sandBoxDomain } respBody, err := utils.HttpDo(ctx, code2SessionPath, utils.HttpPostMethod, string(reqBodyByte), "https", reqDomain) resp := &Code2SessionResponse{} if err != nil { return nil, err } err = json.Unmarshal([]byte(respBody), resp) return resp, err }

Java

@RestController @ComponentScan public class Login { @Autowired private AppConfig appConfig; /** * 小程序利用code2session登录获取openid返回 * @param requestBody * @return * @throws IOException */ @RequestMapping(value = "/api/apps/login", consumes = MediaType.APPLICATION_JSON_VALUE) String handle(@RequestBody String requestBody) throws IOException { // 请求体初始化pb结构 open.douyin.com.model.Login.LoginRequest.Builder requestBuild = open.douyin.com.model.Login.LoginRequest.newBuilder(); JsonFormat.parser().merge(requestBody, requestBuild); open.douyin.com.model.Login.LoginRequest request = requestBuild.build(); // 装配code2session请求体 Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.UPPER_CAMEL_CASE).create(); Code2SessionRequest code2SessionRequest = new Code2SessionRequest(); code2SessionRequest.setCode(request.getCode()); code2SessionRequest.setAppid(appConfig.AppId); code2SessionRequest.setSecret(appConfig.Secret); // code2session的http请求 TODO 常量化 String domain = "developer.toutiao.com"; if (appConfig.IsSandBox.equals("1")) { domain = "open-sandbox.douyin.com"; } String code2SessionPath = "/api/apps/v2/jscode2session"; Code2SessionResponse response = HttpUtil.HttpPost(code2SessionPath, gson.toJson(code2SessionRequest), "https", domain, Code2SessionResponse.class); // 初始化登录返回数据 并处理返回异常 open.douyin.com.model.Login.LoginResponse.Builder builder = open.douyin.com.model.Login.LoginResponse.newBuilder(); // 对返回数据异常处理 if (response == null || response.getData() == null) { builder.setErrCode(-1); builder.setErrMsg("error"); open.douyin.com.model.Login.LoginResponse obj = builder.build(); return JsonFormat.printer().print(obj); } if (response.getErrNo() != 0) { builder.setErrCode(-1); builder.setErrMsg(response.getErrTips()); open.douyin.com.model.Login.LoginResponse obj = builder.build(); return JsonFormat.printer().print(obj); } // 返回成功则组装返回结构体 builder.setErrCode(response.getErrNo()); builder.setErrMsg(""); builder.setErrCode(0); String token = Store.SetCache("", response.getData().getOpenid(), response.getData().getUnionId(), "", response.getData().getSessionKey()); builder.setToken(token); builder.setOpenid(response.getData().getOpenid().substring(0, 3) + "****" + response.getData().getOpenid().substring(7)); builder.setUnionid(response.getData().getUnionId().substring(0, 3) + "****" + response.getData().getUnionId().substring(7)); open.douyin.com.model.Login.LoginResponse responseBody = builder.build(); return JsonFormat.printer().print(responseBody); } }

Python

def login_handler(login_req_body): # 解析登录请求参数 login_req = Parse(login_req_body, login_pb2.LoginRequest(), True) code2session_req = {'appid': conf.app.get_app_id(), 'secret': conf.app.get_secret(), 'code': login_req.code} code2session_res = utils.http_utils.http_post('https://developer.toutiao.com/api/apps/v2/jscode2session', code2session_req) print(code2session_res) data = json.loads(code2session_res) login_res = login_pb2.LoginResponse() if data['err_no'] != 0: login_res.errCode = -1 login_res.errMsg = 'error' return MessageToJson(login_res) else: login_res.errCode = 0 login_res.errMsg = 'success' login_res.token = store.store.set_cache( "", data['data']['openid'], data['data']['unionid'], "", data['data']['session_key']) login_res.openid = data['data']['openid'][:3] + "****" + data['data']['openid'][7:] login_res.unionid = data['data']['unionid'][:3] + "****" + data['data']['unionid'][7:] return MessageToJson(login_res)

PHP

class LoginController extends BaseController { /** * @throws \Exception */ public function login(): string { $this->response->setHeader("Content-Type", "application/json"); try { $json = $this->request->getBody(); $lr = new LoginRequest(); $lr->mergeFromJsonString($json, true); $code2SessionMap = array("appid"=>$_SERVER['APP_ID'], "secret"=>$_SERVER['SECRET'], "code"=>$lr->getCode()); $code2SessionReqBody = json_encode($code2SessionMap); $domain = "developer.toutiao.com"; if ($_SERVER['IS_SAND_BOX'] == "1") { $domain = "open-sandbox.douyin.com"; } $code2SessionRespBody = \App\Utils\HttpUtils::HttpPost("/api/apps/v2/jscode2session", $code2SessionReqBody, "https", $domain); $code2SessionResp = json_decode($code2SessionRespBody, true); $loginResponse = new LoginResponse(); if (empty($code2SessionResp)) { $loginResponse->setErrCode(-1); $loginResponse->setErrMsg("error"); return $loginResponse->serializeToJsonString(); } if ($code2SessionResp['err_no'] != 0 || empty($code2SessionResp["data"])) { $loginResponse->setErrCode(-1); $loginResponse->setErrMsg("error"); return $loginResponse->serializeToJsonString(); } $loginResponse->setErrCode(0); $loginResponse->setErrMsg("success"); $token = \App\Store\Store::setCache("", $code2SessionResp["data"]["openid"], $code2SessionResp["data"]["unionid"], "", $code2SessionResp["data"]["session_key"]); $loginResponse->setToken($token); $loginResponse->setOpenid($this->dataDesensitization($code2SessionResp["data"]["openid"], 3, 7)); $loginResponse->setUnionid($this->dataDesensitization($code2SessionResp["data"]["unionid"], 3, 7)); return $loginResponse->serializeToJsonString(); } catch (\Exception $e) { return $e->getMessage(); } } function dataDesensitization($string, $start = 0, $length = 0, $re = '*') { if (empty($string)){ return false; } $strarr = array(); $mb_strlen = mb_strlen($string); while ($mb_strlen) {//循环把字符串变为数组 $strarr[] = mb_substr($string, 0, 1, 'utf8'); $string = mb_substr($string, 1, $mb_strlen, 'utf8'); $mb_strlen = mb_strlen($string); } $strlen = count($strarr); $begin = $start >= 0 ? $start : ($strlen - abs($start)); $end = $last = $strlen - 1; if ($length > 0) { $end = $begin + $length - 1; } elseif ($length < 0) { $end -= abs($length); } for ($i = $begin; $i <= $end; $i++) { $strarr[$i] = $re; } if ($begin >= $end || $begin >= $last || $end > $last) return false; return implode('', $strarr); } }

JavaScript

// 详细代码请参考 NodeJs/src/app.js const axios = require('axios'); const Router = require('koa-router'); const { v4: uuidv4 } = require('uuid'); // 存储token const tokens = {}; const router = new Router(); // 是否为沙盒环境 const baseURL = sandbox ? 'https://developer.toutiao.com' : 'https://open-sandbox.douyin.com'; const http = axios.create({ baseURL, }); /** * @description: 字符串脱敏,中间四个字符被和谐 * @param {*} str * @return {*} */ function desensitization(str) { const len = str.length; const mid = len >> 1; return `${str.substring(0, mid - 1)}****${str.substring(mid + 2)}`; } // 静默登录接口 router.post('/api/apps/login', async ctx => { // 前端上报tt.login返回的code const { code } = ctx.request.body; // 调用开平code2session接口 const res = await http.request({ url: '/api/apps/v2/jscode2session', method: 'post', data: { secret, code, appid, }, }); // 获取到session_key,用户openid和union_id const { session_key, openid, unionid } = res.data.data; // 根据openid生成token const token = uuidv4(); // 存储到开发者服务端 tokens[token] = { session_key, openid, unionid, token, }; ctx.body = { errCode: 0, errMsg: 'ok', token, openid: desensitization(openid), unionid: desensitization(unionid), }; });

获取手机号

获取手机号能力,满足以下条件自动获取能力权限,无需在控制台申请:
    小程序已上线
时序图:

前端

前端首先需要在 page 的 onLoad 方法调用 tt.login 换取 code,然后在open-type="getPhoneNumber"的 button 组件的 bindgetphonenumber 回调中,将 iv, encryptedData 和 token 一同传给开发者服务端解密用户电话号码。同样,此处也需要考虑用户未登录抖音 App 宿主的情况。
代码示例:
详细代码请参考front-end/pages/phone-number-login/phone-number-login.js文件。
效果展示:

开发者服务端

Go

func ShowData(context context.Context, req *model.ShowDataRequest) *model.ShowDataResponse { userData, exist := store.GetCache(context, req.Token) if !exist { resp := &model.ShowDataResponse{ ErrCode: -1, ErrMsg: "", DecryptedData: "", Token: "", } return resp } println("data: ", req.EncryptedData.Data) println("sessionkey: ", userData.SessionKey) println("iv: ", req.EncryptedData.Iv) decodeData, _ := base64.StdEncoding.DecodeString(req.EncryptedData.Data) decodeSecret, _ := base64.StdEncoding.DecodeString(userData.SessionKey) decodeIv, _ := base64.StdEncoding.DecodeString(req.EncryptedData.Iv) decryptData, err := utils.CbcDecrypt(decodeData, decodeSecret, decodeIv) if err != nil { resp := &model.ShowDataResponse{ ErrCode: -1, ErrMsg: err.Error(), DecryptedData: "", Token: "", } return resp } token := store.SetCache(context, req.Token, userData.OpenId, userData.UnionId, decryptData, userData.SessionKey) resp := &model.ShowDataResponse{ ErrCode: 0, ErrMsg: "success", DecryptedData: utils.MaskPhoneNumber(context, decryptData), Token: token, } return resp }

Java

@RestController @RestController public class ShowData { @Autowired private AppConfig appConfig; /** * 小程序登录解密全流程,code2session->cbc decryption,这里demo对可能存在的加解密异常不做额外处理,开发者可以自行加入处理逻辑 * @param requestBody * @return * @throws IOException * @throws InvalidAlgorithmParameterException * @throws NoSuchPaddingException * @throws IllegalBlockSizeException * @throws NoSuchAlgorithmException * @throws BadPaddingException * @throws InvalidKeyException */ @RequestMapping(value = "/api/apps/showData", consumes = MediaType.APPLICATION_JSON_VALUE) public String handle(@RequestBody String requestBody) throws IOException, InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, BadPaddingException, InvalidKeyException { // 装载showdata请求体 open.douyin.com.model.Login.ShowDataRequest.Builder requestBuild = open.douyin.com.model.Login.ShowDataRequest.newBuilder(); JsonFormat.parser().merge(requestBody, requestBuild); open.douyin.com.model.Login.ShowDataRequest request = requestBuild.build(); UserSession userData = Store.GetCache(request.getToken()); open.douyin.com.model.Login.ShowDataResponse.Builder builder = open.douyin.com.model.Login.ShowDataResponse.newBuilder(); // cbc解密 open.douyin.com.model.Login.EncryptedData data = request.getEncryptedData(); String decryptData = CbcCoding.decrypt(data.getData(), userData.getSessionKey(), data.getIv()); builder.setDecryptedData(Mask.MaskPhoneNumber(decryptData)); String token = Store.SetCache(request.getToken(), userData.getOpenid(), userData.getUnionid(), decryptData, userData.getSessionKey()); builder.setToken(token); Login.ShowDataResponse obj = builder.build(); return JsonFormat.printer().print(obj); } }

Python

def show_data_handler(login_req_body): # 解析登录请求参数,并http调用code2session show_data_req = Parse(login_req_body, login_pb2.ShowDataRequest(), True) user_data = store.store.get_cache(show_data_req.token) # 解密用户加密数据 decode_session_key = base64.b64decode(user_data.get_session_key()) decode_data = base64.b64decode(show_data_req.encryptedData.data) decode_iv = base64.b64decode(show_data_req.encryptedData.iv) decrypt_data = utils.cbc_coding.decrypt(decode_data, decode_session_key, decode_iv) token = store.store.set_cache(show_data_req.token, user_data.get_open_id(), user_data.get_union_id(), decrypt_data, user_data.get_session_key()) show_data_res = login_pb2.ShowDataResponse() show_data_res.decryptedData = utils.mask.mask_phone(decrypt_data) show_data_res.token = token return MessageToJson(show_data_res)

PHP

class ShowDataController extends BaseController { public function showData() { $this->response->setHeader("Content-Type", "application/json"); try { $json = $this->request->getBody(); $showDataReq = new ShowDataRequest(); $showDataReq->mergeFromJsonString($json, true); $showDataResp = new ShowDataResponse(); log_message('critical', $json); $userData = \App\Store\Store::getCache($showDataReq->getToken()); if (empty($userData)) { $showDataResp->setErrCode(-1); $showDataResp->setErrMsg("token not exist"); return $showDataResp->serializeToJsonString(); } log_message('critical', $showDataReq->getToken()); $decodeKey = base64_decode($userData["sessionKey"]); $decodeData = base64_decode($showDataReq->getEncryptedData()->getData()); $decodeIv = base64_decode($showDataReq->getEncryptedData()->getIv()); $decryptData = CbcCoding::decrypt($decodeData, $decodeKey, $decodeIv); if (!$decryptData) { $showDataResp->setErrCode(-1); $showDataResp->setErrMsg("error"); return $showDataResp->serializeToJsonString(); } $showDataResp->setDecryptedData(Mask::MaskPhoneNumber($decryptData)); $showDataResp->setErrCode(0); $showDataResp->setErrMsg("success"); $token = \App\Store\Store::setCache("", $userData["openId"], $userData["unionId"], $decryptData, $userData["sessionKey"]); $showDataResp->setToken($token); return $showDataResp->serializeToJsonString(); } catch (\Exception $e) { return $e->getMessage(); } } }

JavaScript

// 详细代码请参考 NodeJs/src/app.js const axios = require('axios'); const Router = require('koa-router'); const router = new Router(); // 是否为沙盒环境 const baseURL = sandbox ? 'https://developer.toutiao.com' : 'https://open-sandbox.douyin.com'; const http = axios.create({ baseURL, }); // 存储对象,本示例中以object方式代替数据库存储方式,以用户openid作为存储的主键 const storage = {}; // 存储token,每个token对应一个openid const tokens = {}; /** * @description: 解密方法 * @param {string} encryptedData * @param {string} sessionKey * @param {string} iv * @return {string} */ function decrypt(encryptedData, sessionKey, iv) { const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(sessionKey, 'base64'), Buffer.from(iv, 'base64')); let ret = decipher.update(encryptedData, 'base64', 'utf8'); ret += decipher.final('utf8'); return ret; } // 解密用户数据 router.post('/api/apps/showData', ctx => { /** * @description: 前端需要上报tt.login返回的code和tt.getUserProfile和getPhoneNumber返回的encryptedData * @param {{ data: string, iv: string,dataType: number }[]} encryptedData: data(用户加密数据),iv( 随机iv), dataType(1为获取电话;2为获取用户信息) * @return {*} */ const { encryptedData, token } = ctx.request.body; let info = tokens[token]; // 获取本地缓存sessionKey const { session_key } = info; const { data, iv } = encryptedData; // 解密 let decryptedData = null; try { decryptedData = decrypt(data, session_key, iv); decryptedData = JSON.parse(decryptedData); tokens[token].userData = decryptedData; // 此处做了一个脱敏处理 decryptedData.phoneNumber = desensitization(decryptedData.phoneNumber); decryptedData.purePhoneNumber = desensitization(decryptedData.purePhoneNumber); // 下发前端解密数据 ctx.body = { errCode: 0, errMsg: 'ok', decryptedData: JSON.stringify(decryptedData), }; } catch (err) { console.log(err); // 若解密失败,说明sessionKey过期,用户登录失效 ctx.status = 401; ctx.body = { errCode: 1, errMsg: '认证过期', }; delete tokens[token]; } }); // 确定用户登录后调用的返回用户信息接口,需要前端上传token router.post('/api/apps/userData', ctx => { const { token } = ctx.request.body; const user = tokens[token]; if (!user) { ctx.status = 404; ctx.body = { errCode: 1, errMsg: '未找到用户', userData: {}, }; return; } const decryptedData = user && user.userData; if (!decryptedData) { ctx.status = 400; ctx.body = { errCode: 2, errMsg: '请先调用showData接口', userData: {}, }; return; } // 此处做了一个脱敏处理 decryptedData.phoneNumber = desensitization(decryptedData.phoneNumber); decryptedData.purePhoneNumber = desensitization(decryptedData.purePhoneNumber); ctx.body = { errCode: 0, errMsg: 'ok', userData: { data: JSON.stringify(decryptedData) }, }; });

自定义注册

抖音开放平台允许“自定义登录”,但是要求登录页面入口携带“抖音授权登录”按钮,开发者可根据自身需要设计开发第三方登录功能。
同时,我们也强烈建议在“自定义登录”之前,静默获取用户的 open_id 和 union_id。待用户自定义登录成功后,在服务端将 open_id、union_id 与自定义的账号体系打通。这样当用户下次在另一台设备上访问时,可以省略再次登录的流程。
详细代码请参考front-end/pages/custom-login文件夹下的内容。
效果图:
关于“用户协议”和“隐私协议”等相关政策需要开发者制定,官方推荐使用 webview 的方式呈现:将相关政策协议放到 H5 网页中,然后在小程序中单独配置一个页面展示 webview。这样做相对于把相关政策协议放到小程序页面中的好处是,当需要修改政策协议时,只需要修改网页而不需要重新上线小程序,避免由于小程序需要审核所带来的上线延迟。

审核注意事项

    1.提审小程序之前,请确保已完成“用户隐私保护指引”的填写。
    2.获取手机号需要正当的理由。
    允许在:网络购物、账号下信息内容同步、票务预订、业务办理、信息查询(如:社保、公积金)、预约等需要获取用户手机号的场景下使用。
    不允许在:表单填写、投票类、互动类场景下使用。
    3.如果要开放“自定义注册&登录”必须要有“用户协议”和“隐私政策”,且不可默认勾选,必须用户手动勾选。
    4.自定义注册&登录,在UI上必须要将“抖音授权登录”按钮进行强调,详情请看小程序内用户帐号登录规范