ci / verify (push) Canceled after 0s
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
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
|
|
}
|