Files
story/internal/onboarding/wechat.go
T

161 lines
6.4 KiB
Go
Raw Normal View History

package onboarding
import (
"context"
"errors"
"time"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5"
)
type WeChatLoginInput struct {
LoginCode string `json:"login_code"`
PhoneCode string `json:"phone_code,omitempty"`
}
type WeChatPasswordInput struct {
PasswordLoginInput
LoginCode string `json:"login_code"`
}
func (s *Service) weChatPasswordLogin(ctx context.Context, hash string, in WeChatPasswordInput) (map[string]any, error) {
if s.operatorLogin {
return nil, fail(403, "CUSTOMER_REQUIRED", "微信登录仅用于家庭账号")
}
if s.options.WeChat == nil || s.options.WeChatAppID == "" {
return nil, fail(503, "WECHAT_NOT_CONFIGURED", "微信登录尚未开通,请使用手机号登录")
}
if in.LoginCode == "" || len(in.LoginCode) > 512 {
return nil, fail(400, "INVALID_WECHAT_CODE", "请重新发起微信登录")
}
if err := s.limit(ctx, "wechat-login:"+hash, 12, time.Minute); err != nil {
return nil, err
}
openID, err := s.options.WeChat.OpenID(ctx, in.LoginCode)
if err != nil {
return nil, err
}
out, err := s.passwordLoginLinked(ctx, hash, in.PasswordLoginInput, openID)
if err != nil {
return nil, err
}
return map[string]any{"access_token": "customer." + out.AccountToken, "portal": "customer", "expires_in": 86400}, nil
}
func (s *Service) linkWeChatPhone(ctx context.Context, tx pgx.Tx, openID, phoneHash string) error {
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":"+openID); err != nil {
return err
}
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":phone:"+phoneHash); err != nil {
return err
}
var conflict bool
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_wechat_identity WHERE app_id=$1 AND ((open_id=$2 AND phone_digest<>$3) OR (open_id<>$2 AND phone_digest=$3)))`, s.options.WeChatAppID, openID, phoneHash).Scan(&conflict); err != nil {
return err
}
if conflict {
return fail(409, "WECHAT_PHONE_CONFLICT", "微信或手机号已关联其他账号,请使用原账号登录")
}
_, err := tx.Exec(ctx, `INSERT INTO customer_wechat_identity(app_id,open_id,phone_digest) VALUES($1,$2,$3) ON CONFLICT(app_id,open_id) DO NOTHING`, s.options.WeChatAppID, openID, phoneHash)
return err
}
func (s *Service) weChatLogin(ctx context.Context, sessionHash string, in WeChatLoginInput) (map[string]any, error) {
if s.operatorLogin {
return nil, fail(403, "CUSTOMER_REQUIRED", "微信登录仅用于家庭账号")
}
if s.options.WeChat == nil || s.options.WeChatAppID == "" {
return nil, fail(503, "WECHAT_NOT_CONFIGURED", "微信登录尚未开通,请使用手机号登录")
}
if in.LoginCode == "" || len(in.LoginCode) > 512 || len(in.PhoneCode) > 512 {
return nil, fail(400, "INVALID_WECHAT_CODE", "请重新发起微信登录")
}
if err := s.limit(ctx, "wechat-login:"+sessionHash, 12, time.Minute); err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(ctx, 18*time.Second)
defer cancel()
openID, err := s.options.WeChat.OpenID(ctx, in.LoginCode)
if err != nil {
return nil, err
}
// Reuse the existing identity on later logins; a client never supplies an
// openid or an unverified phone number to choose the account it receives.
verifiedPhone := ""
if in.PhoneCode != "" {
verifiedPhone, err = s.options.WeChat.Phone(ctx, in.PhoneCode)
if err != nil {
return nil, err
}
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err = s.checkLoginSession(ctx, tx, sessionHash); err != nil {
return nil, err
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":"+openID); err != nil {
return nil, err
}
var phoneHash string
err = tx.QueryRow(ctx, `SELECT phone_digest FROM customer_wechat_identity WHERE app_id=$1 AND open_id=$2`, s.options.WeChatAppID, openID).Scan(&phoneHash)
fresh := errors.Is(err, pgx.ErrNoRows)
if err != nil && !fresh {
return nil, err
}
if fresh && verifiedPhone == "" {
return map[string]any{"phone_required": true}, nil
}
if verifiedPhone != "" {
if !phonePattern.MatchString(verifiedPhone) {
return nil, fail(400, "WECHAT_PHONE_INVALID", "手机号授权失败")
}
verifiedHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+verifiedPhone)
if !fresh && phoneHash != verifiedHash {
return nil, fail(409, "WECHAT_PHONE_CONFLICT", "微信已关联其他手机号,请使用原账号登录")
}
phoneHash = verifiedHash
}
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,65))`, s.options.WeChatAppID+":phone:"+phoneHash); err != nil {
return nil, err
}
if fresh {
var other bool
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM customer_wechat_identity WHERE app_id=$1 AND phone_digest=$2)`, s.options.WeChatAppID, phoneHash).Scan(&other); err != nil {
return nil, err
}
if other {
return nil, fail(409, "WECHAT_PHONE_CONFLICT", "该手机号已关联其他微信,请使用手机号密码登录")
}
p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash}
masked := verifiedPhone[:len(verifiedPhone)-8] + "****" + verifiedPhone[len(verifiedPhone)-4:]
if _, err = tx.Exec(ctx, `INSERT INTO customer_phone_credential(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4) ON CONFLICT(phone_digest) DO NOTHING`, phoneHash, masked, p.Issuer, p.Subject); err != nil {
return nil, err
}
}
var p identity.Principal
err = tx.QueryRow(ctx, `SELECT issuer,subject,phone_masked FROM customer_phone_credential WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject, &p.DisplayName)
if err != nil {
return nil, err
}
out, err := s.attachCustomerLogin(ctx, tx, sessionHash, p, fresh)
if err != nil {
return nil, err
}
if fresh {
if _, err = tx.Exec(ctx, `INSERT INTO customer_wechat_identity(app_id,open_id,phone_digest) VALUES($1,$2,$3)`, s.options.WeChatAppID, openID, phoneHash); err != nil {
return nil, err
}
}
// Merely logging in with an already-linked OpenID is not a fresh SMS/phone
// verification and must not grant password-reset privileges.
if _, err = tx.Exec(ctx, `UPDATE onboarding_account_session SET phone_digest=$2 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash); err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
return map[string]any{"access_token": "customer." + out.AccountToken, "portal": "customer", "expires_in": 86400}, nil
}