feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
+110
-31
@@ -24,6 +24,10 @@ import (
|
||||
type SMSProvider interface {
|
||||
Send(context.Context, string, string, string) error
|
||||
}
|
||||
|
||||
type purposeSMSProvider interface {
|
||||
SendForPurpose(context.Context, string, string, string, string) error
|
||||
}
|
||||
type HTTPSMSProvider struct {
|
||||
endpoint, token string
|
||||
client *http.Client
|
||||
@@ -62,24 +66,47 @@ func (p *HTTPSMSProvider) Send(ctx context.Context, phone, code, id string) erro
|
||||
var phonePattern = regexp.MustCompile(`^\+[1-9][0-9]{7,14}$`)
|
||||
var smsCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
|
||||
|
||||
const smsCodeLifetime = 5 * time.Minute
|
||||
|
||||
type SMSSendInput struct {
|
||||
Phone string `json:"phone_e164"`
|
||||
Purpose string `json:"purpose"`
|
||||
CaptchaTicket string `json:"captcha_ticket,omitempty"`
|
||||
CaptchaRandstr string `json:"captcha_randstr,omitempty"`
|
||||
Phone string `json:"phone_e164"`
|
||||
Purpose string `json:"purpose"`
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
CaptchaCode string `json:"captcha_code"`
|
||||
}
|
||||
|
||||
func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, in SMSSendInput) (map[string]any, error) {
|
||||
return s.sendSMS(ctx, w.Hash, requestID, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
|
||||
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.checkWebActor(ctx, tx, w.Hash, d)
|
||||
})
|
||||
}
|
||||
|
||||
// table is an internal constant, never supplied by a request. Both login entry
|
||||
// points share phone limits, code hashing, delivery idempotency and attempt caps.
|
||||
func (s *Service) sendSMS(ctx context.Context, sessionHash, requestID string, in SMSSendInput, table string, check func(pgx.Tx) error) (map[string]any, error) {
|
||||
if !s.options.SMSLoginEnabled {
|
||||
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请使用账号登录")
|
||||
return nil, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放,请稍后重试")
|
||||
}
|
||||
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) || in.Purpose != "LOGIN" {
|
||||
if !uuidV4(requestID) || !phonePattern.MatchString(in.Phone) {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "请使用包含国家区号的手机号码")
|
||||
}
|
||||
// These routes authenticate an account (including the existing password
|
||||
// recovery flow). Phone changes require their own authenticated workflow.
|
||||
if in.Purpose != SMSPurposeLogin && in.Purpose != SMSPurposeChangePassword {
|
||||
return nil, fail(400, "VALIDATION_FAILED", "当前接口不支持此短信验证码用途")
|
||||
}
|
||||
phoneHash := domainMAC(s.crypt.hashKey, "PHONE1\n"+in.Phone)
|
||||
// Keyed phone hashes prevent offline enumeration of stored phone numbers.
|
||||
if err := s.limit(ctx, "sms:global", 1000, time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.limit(ctx, "sms:session:"+w.Hash, 10, time.Hour); err != nil {
|
||||
if err := s.limit(ctx, "sms:session:"+sessionHash, 10, time.Hour); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
@@ -87,22 +114,18 @@ func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, i
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
if err = check(tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "sms:"+phoneHash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var id, status, stored string
|
||||
var id, status, stored, storedPurpose string
|
||||
var expires time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at FROM onboarding_sms_challenge WHERE session_hash=$1 AND request_id=$2::uuid`, w.Hash, requestID).Scan(&id, &status, &stored, &expires)
|
||||
err = tx.QueryRow(ctx, `SELECT id::text,status,phone_digest,expires_at,purpose FROM `+table+` WHERE session_hash=$1 AND request_id=$2::uuid`, sessionHash, requestID).Scan(&id, &status, &stored, &expires, &storedPurpose)
|
||||
if err == nil {
|
||||
if stored != phoneHash {
|
||||
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码")
|
||||
if stored != phoneHash || storedPurpose != in.Purpose {
|
||||
return nil, fail(409, "REQUEST_BODY_CONFLICT", "请求号已用于其他手机号码或短信用途")
|
||||
}
|
||||
if status != "SENT" || !expires.After(s.now()) {
|
||||
return nil, fail(409, "SMS_SEND_UNCERTAIN", "请等待重发间隔后重新申请验证码")
|
||||
@@ -112,35 +135,59 @@ func (s *Service) SendSMS(ctx context.Context, w WebSession, requestID string, i
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
// Exact delivery retries above return only the previous result. Every new
|
||||
// delivery must consume a fresh challenge bound to this browser session.
|
||||
if err = s.verifyLoginCaptcha(ctx, sessionHash, in.CaptchaID, in.CaptchaCode, in.CaptchaTicket, in.CaptchaRandstr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var recent, hourCount int
|
||||
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'`, phoneHash).Scan(&recent, &hourCount)
|
||||
err = tx.QueryRow(ctx, `SELECT count(*) FILTER(WHERE created_at>now()-interval '60 seconds'),count(*) FROM (
|
||||
SELECT created_at FROM onboarding_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
|
||||
UNION ALL SELECT created_at FROM customer_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
|
||||
UNION ALL SELECT created_at FROM operator_sms_challenge WHERE phone_digest=$1 AND created_at>now()-interval '1 hour'
|
||||
) sends`, phoneHash).Scan(&recent, &hourCount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if recent > 0 || hourCount >= 5 {
|
||||
return nil, fail(429, "SMS_RATE_LIMITED", "发送过于频繁,请稍后再试")
|
||||
}
|
||||
templatePurpose := in.Purpose
|
||||
if in.Purpose == SMSPurposeLogin && !s.operatorLogin {
|
||||
var registered bool
|
||||
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1)`, phoneHash).Scan(®istered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !registered {
|
||||
templatePurpose = SMSPurposeRegister
|
||||
}
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code := fmt.Sprintf("%06d", n.Int64())
|
||||
id = uuid.NewString()
|
||||
expires = s.now().Add(5 * time.Minute)
|
||||
expires = s.now().Add(smsCodeLifetime)
|
||||
codeHash := domainMAC(s.smsHashKey(), id+"\n"+code)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO onboarding_sms_challenge(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7)`, id, w.Hash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO `+table+`(id,session_hash,phone_digest,phone_ciphertext,code_hash,request_id,expires_at,purpose) VALUES($1::uuid,$2,$3,$4,$5,$6::uuid,$7,$8)`, id, sessionHash, phoneHash, s.crypt.seal([]byte(in.Phone), "sms-phone:"+id), codeHash, requestID, expires, in.Purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sendErr := s.options.SMS.Send(ctx, in.Phone, code, id)
|
||||
var sendErr error
|
||||
if sender, ok := s.options.SMS.(purposeSMSProvider); ok {
|
||||
sendErr = sender.SendForPurpose(ctx, in.Phone, code, id, templatePurpose)
|
||||
} else {
|
||||
sendErr = s.options.SMS.Send(ctx, in.Phone, code, id)
|
||||
}
|
||||
status = "SENT"
|
||||
if sendErr != nil {
|
||||
status = "FAILED"
|
||||
}
|
||||
if _, err = s.pool.Exec(ctx, `UPDATE onboarding_sms_challenge SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
|
||||
if _, err = s.pool.Exec(ctx, `UPDATE `+table+` SET status=$2 WHERE id=$1::uuid AND status='SENDING'`, id, status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sendErr != nil {
|
||||
@@ -159,6 +206,20 @@ type SMSVerifyInput struct {
|
||||
}
|
||||
|
||||
func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput) (BrowserCredentials, error) {
|
||||
var d Device
|
||||
return s.verifySMS(ctx, w.Hash, in, "onboarding_sms_challenge", func(tx pgx.Tx) error {
|
||||
var err error
|
||||
d, err = readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.checkWebActor(ctx, tx, w.Hash, d)
|
||||
}, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
|
||||
return s.attachPrincipal(ctx, tx, w, d, p, true)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) verifySMS(ctx context.Context, sessionHash string, in SMSVerifyInput, table string, check func(pgx.Tx) error, attach func(pgx.Tx, identity.Principal) (BrowserCredentials, error)) (BrowserCredentials, error) {
|
||||
var out BrowserCredentials
|
||||
if !s.options.SMSLoginEnabled {
|
||||
return out, fail(503, "SMS_UNAVAILABLE", "短信登录暂未开放")
|
||||
@@ -171,18 +232,14 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
|
||||
return out, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
d, err := readDevice(ctx, tx, w.Device.ID, true)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
|
||||
if err = check(tx); err != nil {
|
||||
return out, err
|
||||
}
|
||||
var expected, phoneHash, status string
|
||||
var encrypted []byte
|
||||
var attempts int
|
||||
var expires time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM onboarding_sms_challenge WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, w.Hash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
|
||||
err = tx.QueryRow(ctx, `SELECT code_hash,phone_digest,phone_ciphertext,status,attempts,expires_at FROM `+table+` WHERE id=$1::uuid AND session_hash=$2 FOR UPDATE`, in.ChallengeID, sessionHash).Scan(&expected, &phoneHash, &encrypted, &status, &attempts, &expires)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
|
||||
}
|
||||
@@ -193,7 +250,7 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
|
||||
return out, fail(401, "SMS_CODE_INVALID", "验证码错误或已失效")
|
||||
}
|
||||
if !hmac.Equal([]byte(expected), []byte(domainMAC(s.smsHashKey(), in.ChallengeID+"\n"+in.Code))) {
|
||||
if _, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
|
||||
if _, err = tx.Exec(ctx, `UPDATE `+table+` SET attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err = tx.Commit(ctx); err != nil {
|
||||
@@ -207,11 +264,33 @@ func (s *Service) VerifySMS(ctx context.Context, w WebSession, in SMSVerifyInput
|
||||
}
|
||||
masked := "用户" + string(phone[len(phone)-4:])
|
||||
p := identity.Principal{Issuer: "urn:platform:onboarding:sms", Subject: phoneHash, DisplayName: masked}
|
||||
out, err = s.attachPrincipal(ctx, tx, w, d, p, true)
|
||||
// Resolve both login methods to the same verified phone identity.
|
||||
if !s.operatorLogin {
|
||||
_, err = tx.Exec(ctx, `INSERT INTO `+s.phoneCredentialTable()+`(phone_digest,phone_masked,issuer,subject) VALUES($1,$2,$3,$4) ON CONFLICT(phone_digest) DO NOTHING`, phoneHash, string(phone[:len(phone)-8])+"****"+string(phone[len(phone)-4:]), p.Issuer, p.Subject)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
err = tx.QueryRow(ctx, `SELECT issuer,subject FROM `+s.phoneCredentialTable()+` WHERE phone_digest=$1 FOR SHARE`, phoneHash).Scan(&p.Issuer, &p.Subject)
|
||||
if errors.Is(err, pgx.ErrNoRows) && s.operatorLogin {
|
||||
return out, fail(403, "OPERATOR_ACCOUNT_UNAVAILABLE", "该手机号尚未开通管理权限,请联系管理员")
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE onboarding_sms_challenge SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
|
||||
out, err = attach(tx, p)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE `+s.accountSessionTable()+` SET phone_digest=$2,phone_verified_at=$3 WHERE token_hash=$1`, digest([]byte(out.AccountToken)), phoneHash, s.now())
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE `+table+` SET status='CONSUMED',consumed_at=now(),attempts=attempts+1 WHERE id=$1::uuid`, in.ChallengeID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.TrustedDeviceToken, err = s.rememberLoginDevice(ctx, tx, phoneHash)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -223,11 +302,11 @@ func (s *Service) AccountPrincipal(ctx context.Context, token, csrf string, writ
|
||||
var p identity.Principal
|
||||
var expected string
|
||||
if !validB64(token, 32) {
|
||||
return p, errAccess
|
||||
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
|
||||
}
|
||||
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM onboarding_account_session ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ss.token_hash=$1 AND ss.expires_at>now() AND ss.revoked_at IS NULL`, digest([]byte(token))).Scan(&p.Issuer, &p.Subject, &p.DisplayName, &expected)
|
||||
err := s.pool.QueryRow(ctx, `SELECT ua.oidc_issuer,ua.oidc_subject,COALESCE(ua.display_name,''),ss.csrf_hash FROM `+s.accountSessionTable()+` ss JOIN user_account ua ON ua.id=ss.user_id AND ua.status='active' AND `+s.accountPredicate()+` JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ss.token_hash=$1 AND ss.expires_at>now() AND ss.revoked_at IS NULL`, digest([]byte(token))).Scan(&p.Issuer, &p.Subject, &p.DisplayName, &expected)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return p, errAccess
|
||||
return p, fail(401, "LOGIN_REQUIRED", "登录已失效,请重新登录")
|
||||
}
|
||||
if err != nil {
|
||||
return p, err
|
||||
|
||||
Reference in New Issue
Block a user