基于Taro的微信小程序登录授权方案

10,466 阅读9分钟

背景

登录是一个项目的基础功能,是唯一确定一个用户的必备路径,登录成功之后才能进行业务接口的调用,与服务端进行数据的交互。

一个项目的登录方案的设计需要考虑到方方面面,小程序因为中间多了一个授权的步骤,尤其更加复杂

从健壮性和高效性出发,我们一步一步来把整个登录方案设计出来,要注意的是,这个方案是基于Taro框架之上的,用的是React语法

基本流程

先来看一下官方给出的小程序登录流程时序图

image.png

是不是感觉很复杂?不要被吓到了,其实我们完全可以不用严格按照上面的流程图进行各种前后端的数据交互、验证

我们先抛开小程序这一层,先想一下,一个正常项目的登录接口是什么样的?其实无非就是将账号+密码作为参数带给服务端,服务端进行验证或者注册之后生成token返回回来,我们再保存这个token,这就算登录成功了,考虑到具体需求,可以把账号密码替换成手机号

获取手机号

基于这个逻辑,其实我们只需要获取用户绑定的手机号,就能完成登录这一流程。那么如何获取到用户绑定到微信的手机号呢?这时候小程序的作用就体现出来了,我们可以通过弹起手机号授权弹窗让用户允许从而来获取到他的手机号,再把这个手机号给到后端。

但是由于微信隐私规范的限制,我们不能直接地拿到这个手机号,我们需要通过授权回调拿到的iv+encryptedData两个加密信息解密出手机号,解密用的秘钥则是上面流程图提到的session_key,但是在前端我们并不需要直接接触到这个字段,session_key是由wx.login()拿到的code通过调用微信的登录凭证校验接口拿到的,我们可以直接调用wx.login()拿到code,将code与iv、 encryptedData传给服务端,由服务端解密出手机号进行登录。

服务端登录之后除了把token返回回来,还需要将用户的手机号返回回来,我们保存到缓存里,下次token失效可以直接用手机号进行登录。

登录按钮

还有一个地方需要注意,由于微信近年来更新了用户隐私规范,授权窗口只能通过规定的按钮由用户点击触发,无法通过api的方式调起,所以我们需要封装一个登录按钮

需要注意的是

在回调中调用 wx.login 登录,可能会刷新登录态。此时服务器使用 code 换取的 sessionKey 不是加密时使用的 sessionKey,导致解密失败。建议开发者提前进行 login

所以我们必须保证wx.login在授权弹窗弹起前就调用,可以选择在按钮组件构建完之后调用

// LoginButton.ts
const LoginButton = React.memo((props: IProps) => {
  const { children, onSuccess, onClick, ...other } = props;
  const [code, setCode] = useState('');
  useEffect(() => {
    if (!code) {
      // 实际上是调用了wx.Login()
      Login.getWxCode().then(wxCode => {
        setCode(wxCode);
      }); 
    }
  }, [code]);
  const handleGetPhoneNumber = async (e: BaseEventOrig<ButtonProps.onGetPhoneNumberEventDetail>) => {
    const { iv, encryptedData, errMsg } = e.detail;
    try {
      if (!code) {
        Toast.info('code为空');
        return;
      }
      if (errMsg.includes('ok')) {
        Taro.showLoading({ title: '正在登录' });
        await Login.login({ iv, encryptedData, code: code });
        Taro.hideLoading();
        onSuccess?.();
        Toast.success('登录成功');
      }
    } catch (error) {
      // 解密失败重新login
      Login.getWxCode().then(wxCode => {
        setCode(wxCode);
      });
      Taro.hideLoading();
      Toast.info(error.message || error.errMsg);
    }
  };
  const handleBtnClick = async event => {
    onClick?.(event);
  };

  return (
    // @ts-ignore
    <Button openType="getPhoneNumber" onClick={handleBtnClick} onGetPhoneNumber={handleGetPhoneNumber} {...other}>
      {children}
    </Button>
  );
});

登录逻辑

class Login {
...
// 登录
async login({ iv, encryptedData, code }: { iv: string; encryptedData: string; code: string }) {
  const [err, data] = await login({
    code: code,
    iv: iv,
    encryptedData: encryptedData,
  });
  if (err) {
    Toast.info(err.message);
    throw err;
  } else {
    await this.saveLoginData(data);
  }
}

// 保存用户信息
async saveLoginData(loginData: IMaLoginRet) {
  if (!loginData) {
    return;
  }
  const { phone, userId, tokenId } = loginData;
  await Taro.setStorage({
    key: StorageKey.USER_DATA,
    data: {
      phone,
      userId,
    },
  });
  await Taro.setStorage({
    key: StorageKey.ACCESS_TOKEN,
    data: tokenId,
  });
}
...
}

流程图

总结下来,小程序的登录走的还是正常的业务登录流程,小程序只是通过授权提供一个手机号来供业务登录使用,明白原理之后,我们可以画出一个基本的小程序登录流程图

触发登录的时机

有了以上登录的基本流程,接下来需要考虑一个问题:这套登录流程该用在哪里?

登录的场景

一个基本的小程序项目,至少有两个登录场景:

1、 登录按钮点击触发

这个是最普通的场景了,一般在用户未登录的时候个人中心等页面会有一个提示用户去登录的按钮,这个按钮直接使用我们上述封装好的登录按钮再调整一下样式即可

2、 页面进入触发

简单的按钮点击触发其实是远远不够的,我们希望每个需要登录的页面进入之后如果用户未登录,能够自动登录,这个对通过分享打开的页面尤其重要

我们主要说第二种场景

页面进入触发登录流程

要实现这样的需求,我们需要考虑三个问题

1、如何判断是否已登录

判断是否已登录,只需要判断缓存中的token是否存在即可,至于登录过期的情况,我们这里不考虑,避免多次调接口,过期的情况后面会讨论

2、如何绕过微信的授权限制

上述已经说过,现在无法通过直接调用api的方式授权,只能通过按钮来授权,那么不可能所有页面都有一个常驻的登录按钮给你登录,这时候该如何做的呢?

我们可以使用弹窗的方式来实现,给用户弹起一个是否去登录的提示弹窗,在下方放置我们上面已经封装好的登录按钮,让用户自己点击登录。

3、如何复用登录逻辑

我们不可能让所有的开发者写一个页面就去写一遍登录逻辑,我们需要把逻辑抽离出来,对页面开发者透明

如何复用呢?我们首先想到的是写一个工具方法,把登录的逻辑放在里面,每次调用业务接口之前都去调用这个工具方法。

这种方式有什么问题呢?

  • 不够透明 页面的开发者还是需要在componentDidMount里调用登录方法,对开发者不够透明

  • 难以设置自定义弹窗

登录弹窗需要使用自定义弹窗来实现,而弹窗组件需要放置在render()里,没方法放到一个工具方法内使用

  • 难以清除事件监听

如果工具方法设置了事件监听,页面结束时需要清除监听,同样无法做到

劫持类组件

基于以上的问题,我们抛弃了这种不太友好的做法,采用了另一种方式:劫持类组件,利用React的高阶组件的机制,通过劫持类组件的componentDidMount,render方法来实现登录逻辑的注入,我们来看代码

function WithLogin() {
  return function withComponent<T extends ComponentClass>(Component: T) {
    // @ts-ignore
    return class extends Component {
      loginConfirmRef: IConfirmRef | null = null    
      componentDidMount = async () => {
        // 检测是否存在token
        const hasToken = Login.checkLogin();
        // 如果未登录
        if (!hasToken) {
          this.loginConfirmRef?.show({
            title: '您未登录',
            message: '登录后可查看更多信息',
            buttons: [
              {
                text: '取消',
                type: 'default',
                onClick: () => {
                  this.loginConfirmRef?.hide();
                },
              },
              <LoginButton
                type="primary"
                onClick={() => {
                  this.loginConfirmRef?.hide();
                }}
              >
                去登录
              </LoginButton>,
            ],
          });
        } else {
          super.componentDidMount?.();
        }
      };
   
      render() {
        return (
          <View>
            {super.render()}
            <Confirm
              ref={ref => {
                this.loginConfirmRef = ref;
              }}
            />
          </View>
        );
      }
    };
  };
}

这里首先劫持了页面的componentDidMount,做一下登录判断,如果已登录则调用原本页面的componentDidMount,如果未登录则弹起登录弹窗

劫持render的目的主要是往页面嵌入了一个自定义弹窗组件,根据需求也可以动态设置未登录的页面样式

事件监听

我们想想,还少了什么?登录成功我们是不是要做什么?

是的,需要刷新页面。这里需要用到事件监听的机制,注册一个登录成功的事件监听,登录成功之后触发相应事件,修改后的代码如下

withLogin.ts

function WithLogin() {
  return function withComponent<T extends ComponentClass>(Component: T) {
    // @ts-ignore
    return class extends Component {
      loginConfirmRef: IConfirmRef | null = null;
      
      // 登录成功之后重新调用componentDidMount
      onLoginSuccess = () => {
        super.componentDidMount?.();
      };

      componentDidMount = async () => {
        // 检测是否存在token
        const hasToken = Login.checkLogin();
        // 如果未登录
        if (!hasToken) {
          Taro.eventCenter.on(EventName.LOGIN_SUCCESS, this.onLoginSuccess); // 注册登录成功监听
          this.loginConfirmRef?.show({
            title: '您未登录',
            message: '登录后可查看更多信息',
            buttons: [
              {
                text: '取消',
                type: 'default',
                onClick: () => {
                  this.loginConfirmRef?.hide();
                },
              },
              <LoginButton
                type="primary"
                onClick={() => {
                  this.loginConfirmRef?.hide();
                }}
              >
                去登录
              </LoginButton>,
            ],
          });
        } else {
          super.componentDidMount?.();
        }
      };
      componentWillUnmount = () => {
        Taro.eventCenter.off(EventName.LOGIN_SUCCESS, this.onLoginSuccess); // 清除监听
      };
      render() {
        return (
          <View>
            {super.render()}
            <Confirm
              ref={ref => {
                this.loginConfirmRef = ref;
              }}
            />
          </View>
        );
      }
    };
  };
}

Login.ts

async login({ iv, encryptedData, code }: { iv: string; encryptedData: string; code: string }) {
  const [err, data] = await maAuthorizeLogin({
    code: code,
    iv: iv,
    encryptedData: encryptedData,
  });
  if (err) {
    Toast.info(err.message);
    throw err;
  } else {
    console.log(data);
    await this.saveLoginData(data);
    Taro.eventCenter.trigger(EventName.LOGIN_SUCCESS); // 触发登录成功事件
  }
}

装饰器

我们有时不希望采用高阶组件原本的使用方法,希望采用更加直观清晰的显示形式,这时候我们就需要用到装饰器了

如下所示

@withLogin()
class xxScreen extent Component{
}

简单易懂

流程图

综上所述,我们可以画出我们的第二个流程图

token 过期

以上所诉是初次登录的流程,我们还需要考虑到:token过期了怎么办?

token过期,然而缓存中还是存在token,这样页面进入的时候不会再走登录流程,调业务接口就会报401错误

我们不希望在上诉的登录流程里增加一个步骤去调接口判断token是否过期,我们还是希望接口尽量少,流程更简便

重新登录

如果不能改初次登录流程,就只能从业务接口的调用结果下手了,判断接口返回错误码是否是401,如果缓存中存在token,证明是token过期,这时候我们再重新登录

静默登录

重新登录的时候可以再走一次初次登录流程,但是我们希望尽量减少用户的操作,重新登录的过程应该对用户无感知

我们可以先把业务接口保存起来,再利用初次登录保存到缓存中的手机号执行登录接口,登录成功之后,重新再调用业务接口

请求多个业务接口

上述说的一个业务接口的情况,如果同时请求多个接口,我们不可能每个接口报401之后都去调一遍登录接口,我们需要建一个数组来保存所有业务接口,静默登录完之后再重新调用所有业务接口

完整代码如下:

http.ts

async request(options: OptionType) {
   ...
  return new Promise((resolve, reject) => {
    Taro.request(newOptions)
      .then(async res => {
        const { statusCode } = res;
        const requestWithNewToken = async () => {
          if (!accessToken) {
            // 如果是没有token引起的错误
            reject(new HttpError(HttpCode.UNAUTHORIZED, ErrorMessage[HttpCode.UNAUTHORIZED]));
          } else {
            try {
              // 如果是token过期引起的错误
              await Login.updateToken();
              const data = await this.request(options);
              resolve(data);
            } catch (e) {
              reject(e);
            }
          }
        };
        if (statusCode == HttpCode.SUCCESS) {
          ...
        } else if (statusCode === HttpCode.UNAUTHORIZED) {
          // 如果登录过期或者未登录
          await requestWithNewToken();
        } else {
          reject(new HttpError(statusCode, ErrorMessage[statusCode] || ErrorMessage[HttpCode.SERVER_ERROR]));
        }
      })
      .catch(err => {
        ...
      });
  });
}

Login.ts


class Login {
  private tokenHub = new TokenHub();
  // 是否正在调接口刷新token
  private tokenRefreshing = false;

/**
 * 更新token,并在更新token后重新调用接口
 */
updateToken = (): Promise<void> => {
  return new Promise(async (resolve, reject) => {
    if (this.tokenRefreshing) {
      this.tokenHub.addSubscribers(resolve, reject);
      return;
    }
    this.tokenRefreshing = true;
    const isSuccess = await this.reLogin();
    if (isSuccess) {
      resolve();
      this.tokenHub.notify();
    } else {
      reject(new HttpError(HttpCode.REFRESH_TOKEN_ERROR, ErrorMessage[HttpCode.REFRESH_TOKEN_ERROR]));
      this.tokenHub.rejectAll(
        new HttpError(HttpCode.REFRESH_TOKEN_ERROR, ErrorMessage[HttpCode.REFRESH_TOKEN_ERROR])
      );
    }
    this.tokenRefreshing = false;
  });
};

}

tokenHub.ts

class TokenHub {
  subscribers: ((params?: any) => void)[][] = [];
  addSubscribers(resolve: (params?: any) => void, reject: (params?: any) => void) {
    this.subscribers.push([resolve, reject]);
  }
  notify() {
    this.subscribers.forEach(callbacks => {
      callbacks?.[0]?.();
    });
    this.subscribers = [];
  }
  rejectAll(params: any) {
    this.subscribers.forEach(callbacks => {
      callbacks?.[1]?.(params);
    });
    this.subscribers = [];
  }
}

流程图

综上所述,我们可以画出我们最终的流程图