161 lines
6.4 KiB
Go
161 lines
6.4 KiB
Go
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
|
||
|
|
}
|