Files

447 lines
18 KiB
Go
Raw Permalink Normal View History

package onboarding
import (
"context"
"crypto/hmac"
"crypto/sha256"
"errors"
"time"
"data-terminal-cloud/internal/identity"
"data-terminal-cloud/internal/saas"
"github.com/jackc/pgx/v5"
)
type WebSession struct {
Hash, SetupID, UserID, Issuer, Subject string
Device Device
Expires time.Time
}
type BrowserCredentials struct {
TrustedDeviceToken string `json:"-"`
Token string `json:"-"`
CSRF string `json:"csrf_token"`
AccountToken string `json:"-"`
Data map[string]any `json:"data"`
}
func (s *Service) webSession(ctx context.Context, q queryer, hash string) (WebSession, error) {
var w WebSession
var id string
err := q.QueryRow(ctx, `SELECT ws.token_hash,ws.setup_id::text,COALESCE(ws.user_id::text,''),COALESCE(ws.issuer,''),COALESCE(ws.subject,''),ws.expires_at,ss.device_id::text FROM onboarding_web_session ws JOIN device_setup_session ss ON ss.id=ws.setup_id WHERE ws.token_hash=$1 AND ws.revoked_at IS NULL AND ws.expires_at>now()`, hash).Scan(&w.Hash, &w.SetupID, &w.UserID, &w.Issuer, &w.Subject, &w.Expires, &id)
if errors.Is(err, pgx.ErrNoRows) {
return w, fail(401, "WEB_SESSION_EXPIRED", "请从设备重新打开设置入口")
}
if err != nil {
return w, err
}
w.Device, err = readDevice(ctx, q, id, false)
return w, err
}
func (s *Service) checkWebActor(ctx context.Context, tx pgx.Tx, hash string, d Device) error {
w, err := s.webSession(ctx, tx, hash)
if err != nil {
return err
}
if w.Device.ID != d.ID {
return errAccess
}
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
if err != nil {
return err
}
// A closed setup can report a completed binding, but cannot authorize writes.
if setup.Closed != nil || !setup.Expires.After(s.now()) {
return fail(410, "SETUP_EXPIRED", "设置入口已关闭,请从设备列表查看结果")
}
if w.UserID != "" {
var active bool
err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_account WHERE id=$1::uuid AND tenant_id=$2::uuid AND status='active')`, w.UserID, d.TenantID).Scan(&active)
if err != nil {
return err
}
if !active {
return errAccess
}
}
return nil
}
func (s *Service) checkCSRF(ctx context.Context, hash, token string) error {
if !validB64(token, 32) {
return errAccess
}
var expected string
err := s.pool.QueryRow(ctx, `SELECT csrf_hash FROM onboarding_web_session WHERE token_hash=$1 AND revoked_at IS NULL AND expires_at>now()`, hash).Scan(&expected)
if err != nil || !hmac.Equal([]byte(expected), []byte(digest([]byte(token)))) {
return errAccess
}
return nil
}
type ExchangeInput struct {
Token string `json:"token"`
Verifier string `json:"verifier"`
}
func (s *Service) Exchange(ctx context.Context, in ExchangeInput) (BrowserCredentials, error) {
var out BrowserCredentials
if !validB64(in.Token, 32) || !validB64(in.Verifier, 32) {
return out, errAccess
}
var deviceID string
err := s.pool.QueryRow(ctx, `SELECT ss.device_id::text FROM device_handoff h JOIN device_setup_session ss ON ss.id=h.setup_id WHERE h.token_hash=$1`, digest([]byte(in.Token))).Scan(&deviceID)
if errors.Is(err, pgx.ErrNoRows) {
return out, errAccess
}
if err != nil {
return out, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return out, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, deviceID, true)
if err != nil {
return out, err
}
if !deviceAllowed(d) {
return out, errAccess
}
var setupID, challenge string
var expires time.Time
var consumed *time.Time
err = tx.QueryRow(ctx, `SELECT setup_id::text,code_challenge,expires_at,consumed_at FROM device_handoff WHERE token_hash=$1 FOR UPDATE`, digest([]byte(in.Token))).Scan(&setupID, &challenge, &expires, &consumed)
if err != nil {
return out, err
}
sum := sha256.Sum256([]byte(in.Verifier))
if consumed != nil || !expires.After(s.now()) || !hmac.Equal([]byte(challenge), []byte(encode64(sum[:]))) {
return out, fail(410, "HANDOFF_INVALID", "入口已失效,请从设备获取新入口")
}
setup, err := readSetup(ctx, tx, setupID, d, true)
if err != nil {
return out, err
}
if setup.Mode != "ENROLL" {
return out, errAccess
}
st, err := s.state(ctx, tx, d)
if err != nil {
return out, err
}
if st.BindingState == "BOUND" {
return out, errAccess
}
out.Token = randomToken(32)
out.CSRF = randomToken(32)
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,expires_at) VALUES($1,$2::uuid,$3,$4)`, digest([]byte(out.Token)), setupID, digest([]byte(out.CSRF)), setup.Expires)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE device_handoff SET consumed_at=now() WHERE token_hash=$1`, digest([]byte(in.Token)))
if err != nil {
return out, err
}
out.Data = map[string]any{"setup_id": setupID, "device_id": d.FactoryID, "state": st, "expires_at_ms": setup.Expires.UnixMilli(), "sms_enabled": s.options.SMSLoginEnabled}
return out, tx.Commit(ctx)
}
func (s *Service) attachPrincipal(ctx context.Context, tx pgx.Tx, w WebSession, d Device, p identity.Principal, allowSignup bool) (BrowserCredentials, error) {
var out BrowserCredentials
var user string
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return out, err
}
_, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "account:"+d.TenantID+":"+p.Issuer+":"+p.Subject)
if err != nil {
return out, err
}
err = tx.QueryRow(ctx, `SELECT id::text FROM user_account WHERE tenant_id=$1::uuid AND oidc_issuer=$2 AND oidc_subject=$3 AND status='active' FOR SHARE`, d.TenantID, p.Issuer, p.Subject).Scan(&user)
if errors.Is(err, pgx.ErrNoRows) {
var tenantPublic string
if err = tx.QueryRow(ctx, `SELECT public_id::text FROM tenant WHERE id=$1::uuid`, d.TenantID).Scan(&tenantPublic); err != nil {
return out, err
}
allowed := false
for _, id := range s.options.SelfSignupTenantIDs {
if id == tenantPublic {
allowed = true
}
}
if !allowSignup || !allowed {
return out, fail(403, "TENANT_MEMBERSHIP_REQUIRED", "请先由该租户管理员授权账号")
}
err = tx.QueryRow(ctx, `INSERT INTO user_account(tenant_id,oidc_issuer,oidc_subject,display_name,platform_role,account_kind) VALUES($1::uuid,$2,$3,$4,'customer','customer') RETURNING id::text`, d.TenantID, p.Issuer, p.Subject, p.DisplayName).Scan(&user)
}
if err != nil {
return out, err
}
out.Token = randomToken(32)
out.CSRF = randomToken(32)
out.AccountToken = randomToken(32)
_, err = tx.Exec(ctx, `INSERT INTO onboarding_web_session(token_hash,setup_id,csrf_hash,user_id,issuer,subject,expires_at) VALUES($1,$2::uuid,$3,$4::uuid,$5,$6,$7)`, digest([]byte(out.Token)), w.SetupID, digest([]byte(out.CSRF)), user, p.Issuer, p.Subject, w.Expires)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `UPDATE onboarding_web_session SET revoked_at=now() WHERE token_hash=$1`, w.Hash)
if err != nil {
return out, err
}
_, err = tx.Exec(ctx, `INSERT INTO onboarding_account_session(token_hash,csrf_hash,user_id,expires_at) VALUES($1,$2,$3::uuid,$4)`, digest([]byte(out.AccountToken)), digest([]byte(out.CSRF)), user, s.now().Add(24*time.Hour))
if err != nil {
return out, err
}
out.Data = map[string]any{"authenticated": true, "setup_id": w.SetupID}
return out, nil
}
func (s *Service) AuthLink(ctx context.Context, w WebSession, p identity.Principal) (BrowserCredentials, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return BrowserCredentials{}, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return BrowserCredentials{}, err
}
out, err := s.attachPrincipal(ctx, tx, w, d, p, true)
if err != nil {
return out, err
}
return out, tx.Commit(ctx)
}
type ClaimInput struct {
ElderID string `json:"elder_id"`
Generation int64 `json:"expected_binding_generation"`
}
func (s *Service) Claim(ctx context.Context, w WebSession, requestID string, raw []byte, in ClaimInput) (map[string]any, error) {
if w.UserID == "" {
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
}
if !uuidValid(in.ElderID) || in.Generation < 0 {
return nil, fail(400, "VALIDATION_FAILED", "请选择使用者")
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "claim", requestID, raw, 120*time.Second, func(tx pgx.Tx, d Device) (map[string]any, error) {
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return nil, err
}
if d.Generation != in.Generation {
return nil, errState
}
setup, err := readSetup(ctx, tx, w.SetupID, d, true)
if err != nil {
return nil, err
}
if setup.Mode != "ENROLL" {
return nil, errAccess
}
var household, subject string
err = tx.QueryRow(ctx, `SELECT id::text,household_id::text FROM subject_profile WHERE public_id=$1::uuid AND tenant_id=$2::uuid AND status='active'`, in.ElderID, d.TenantID).Scan(&subject, &household)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
if err = claimPermission(ctx, tx, claimRecord{UserID: w.UserID, HouseholdID: household, SubjectID: subject, TenantID: d.TenantID}); err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
if err != nil {
return nil, err
}
if st.BindingState == "BOUND" {
return nil, errState
}
return newClaim(ctx, s, tx, d, setup.ID, w.UserID, household, subject)
})
}
type CancelInput struct {
ClaimID string `json:"claim_id"`
Generation int64 `json:"expected_binding_generation"`
}
func (s *Service) Cancel(ctx context.Context, w WebSession, requestID string, raw []byte, in CancelInput) (map[string]any, error) {
if !uuidV4(in.ClaimID) || in.Generation < 0 || w.UserID == "" {
return nil, errAccess
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "cancel", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return nil, err
}
c, err := readClaim(ctx, tx, in.ClaimID, d.ID)
if err != nil {
return nil, err
}
if c.UserID != w.UserID || c.SetupID != w.SetupID {
return nil, errAccess
}
if err = claimPermission(ctx, tx, c); err != nil {
return nil, err
}
if c.Status == "CONFIRMED" && d.Generation == c.Generation+1 {
st, e := s.state(ctx, tx, d)
return map[string]any{"already_bound": true, "state": st}, e
}
if d.Generation != in.Generation {
return nil, errState
}
tag, err := tx.Exec(ctx, `UPDATE device_binding_claim SET status='CANCELLED',completed_at=now() WHERE id=$1::uuid AND status='PENDING'`, c.ID)
if err != nil {
return nil, err
}
if tag.RowsAffected() > 0 {
if err = bump(ctx, tx, d.ID); err != nil {
return nil, err
}
}
st, err := s.state(ctx, tx, d)
return map[string]any{"state": st}, err
})
}
func (s *Service) WebStatus(ctx context.Context, w WebSession) (map[string]any, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
d, err := readDevice(ctx, tx, w.Device.ID, true)
if err != nil {
return nil, err
}
if !deviceAllowed(d) {
return nil, errAccess
}
setup, err := readSetup(ctx, tx, w.SetupID, d, false)
if err != nil {
return nil, err
}
st, err := s.state(ctx, tx, d)
if err != nil {
return nil, err
}
var binding *string
var deviceSaved bool
var mine *string
if w.UserID != "" {
err = tx.QueryRow(ctx, `SELECT b.public_id::text,EXISTS(SELECT 1 FROM device_state_ack a WHERE a.device_id=b.device_id AND a.binding_generation=b.generation AND a.boot_id=$3::uuid) FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$2::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' WHERE b.device_id=$1::uuid AND b.unbound_at IS NULL`, d.ID, w.UserID, d.BootID).Scan(&binding, &deviceSaved)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
err = tx.QueryRow(ctx, `SELECT id::text FROM device_binding_claim WHERE setup_id=$1::uuid AND user_id=$2::uuid AND status='PENDING' AND expires_at>now()`, w.SetupID, w.UserID).Scan(&mine)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, err
}
}
// Never expose a different household's progress through an old handoff.
if st.BindingState == "BOUND" && binding == nil {
return nil, errAccess
}
return map[string]any{"setup_id": w.SetupID, "state": st, "binding_id": binding, "claim_id": mine, "device_saved": deviceSaved, "authenticated": w.UserID != "", "closed": setup.Closed != nil || !setup.Expires.After(s.now()), "sms_enabled": s.options.SMSLoginEnabled, "expires_at_ms": setup.Expires.UnixMilli()}, tx.Commit(ctx)
}
type WebAckInput struct {
BindingID string `json:"binding_id"`
Generation int64 `json:"binding_generation"`
Revision int64 `json:"received_revision"`
}
func (s *Service) WebAck(ctx context.Context, w WebSession, requestID string, raw []byte, in WebAckInput) (map[string]any, error) {
if !uuidValid(in.BindingID) || in.Generation < 1 || in.Revision < 1 || w.UserID == "" {
return nil, errAccess
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "web-ack", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
var bindingID string
err := tx.QueryRow(ctx, `SELECT b.id::text FROM device_binding b JOIN household_member hm ON hm.household_id=b.household_id AND hm.user_id=$3::uuid AND hm.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN device_state_snapshot ss ON ss.device_id=b.device_id AND ss.revision=$5 AND ss.binding_generation=b.generation AND ss.state->>'binding_state'='BOUND' WHERE b.device_id=$1::uuid AND b.public_id=$2::uuid AND b.unbound_at IS NULL AND b.generation=$4`, d.ID, in.BindingID, w.UserID, in.Generation, in.Revision).Scan(&bindingID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, errState
}
if err != nil {
return nil, err
}
if d.Generation != in.Generation {
return nil, errState
}
_, err = tx.Exec(ctx, `UPDATE device_setup_session SET web_ack_binding_id=$2::uuid,web_ack_generation=$3,web_ack_revision=$4,web_ack_at=now() WHERE id=$1::uuid`, w.SetupID, bindingID, in.Generation, in.Revision)
return map[string]any{"accepted": true}, err
})
}
func (s *Service) Profiles(ctx context.Context, w WebSession) (map[string]any, error) {
if w.UserID == "" {
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
}
rows, err := s.pool.Query(ctx, `SELECT h.public_id::text,h.name,sp.public_id::text,sp.preferred_name FROM household_member hm JOIN user_account ua ON ua.id=hm.user_id AND ua.status='active' JOIN household h ON h.id=hm.household_id AND h.tenant_id=hm.tenant_id AND h.status='active' LEFT JOIN subject_profile sp ON sp.household_id=h.id AND sp.tenant_id=h.tenant_id AND sp.status='active' WHERE hm.user_id=$1::uuid AND hm.tenant_id=$2::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') ORDER BY h.name,sp.preferred_name`, w.UserID, w.Device.TenantID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var h, n string
var id, name *string
if err = rows.Scan(&h, &n, &id, &name); err != nil {
return nil, err
}
items = append(items, map[string]any{"household_id": h, "household_name": n, "elder_id": id, "preferred_name": name})
}
return map[string]any{"items": items}, rows.Err()
}
type ProfileInput struct {
InitialConsents saas.InitialConsentSelection `json:"initial_consents,omitempty"`
HouseholdID string `json:"household_id,omitempty"`
HouseholdName string `json:"household_name,omitempty"`
Name string `json:"preferred_name"`
}
func (s *Service) CreateProfile(ctx context.Context, w WebSession, requestID string, raw []byte, in ProfileInput) (map[string]any, error) {
if err := saas.ValidateInitialConsents(in.InitialConsents); err != nil {
return nil, fail(400, "VALIDATION_FAILED", "请逐项选择授权并确认用途")
}
if w.UserID == "" || !nonempty(in.Name, 120) || (in.HouseholdID == "" && !nonempty(in.HouseholdName, 200)) || (in.HouseholdID != "" && !uuidValid(in.HouseholdID)) {
return nil, fail(400, "VALIDATION_FAILED", "家庭和使用者信息不正确")
}
return s.mutate(ctx, w.Device, "web:"+w.Hash, "profile", requestID, raw, 24*time.Hour, func(tx pgx.Tx, d Device) (map[string]any, error) {
if err := s.checkWebActor(ctx, tx, w.Hash, d); err != nil {
return nil, err
}
var household, public string
var err error
if in.HouseholdID == "" {
err = tx.QueryRow(ctx, `INSERT INTO household(tenant_id,name,owner_user_id) VALUES($1::uuid,$2,$3::uuid) RETURNING id::text,public_id::text`, d.TenantID, in.HouseholdName, w.UserID).Scan(&household, &public)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `INSERT INTO household_member(tenant_id,household_id,user_id,role) VALUES($1::uuid,$2::uuid,$3::uuid,'owner')`, d.TenantID, household, w.UserID)
} else {
err = tx.QueryRow(ctx, `SELECT h.id::text,h.public_id::text FROM household h JOIN household_member hm ON hm.household_id=h.id AND hm.tenant_id=h.tenant_id WHERE h.public_id=$1::uuid AND h.tenant_id=$2::uuid AND h.status='active' AND hm.user_id=$3::uuid AND hm.status='active' AND hm.role IN ('owner','admin','editor') FOR SHARE OF h,hm`, in.HouseholdID, d.TenantID, w.UserID).Scan(&household, &public)
}
if errors.Is(err, pgx.ErrNoRows) {
return nil, errAccess
}
if err != nil {
return nil, err
}
var id string
err = tx.QueryRow(ctx, `INSERT INTO subject_profile(tenant_id,household_id,preferred_name) VALUES($1::uuid,$2::uuid,$3) RETURNING public_id::text`, d.TenantID, household, in.Name).Scan(&id)
if err != nil {
return nil, err
}
if err = saas.InitializeSubjectMemory(ctx, tx, d.TenantID, id, w.UserID, requestID, in.InitialConsents); err != nil {
return nil, err
}
if err = s.auditEvent(ctx, tx, d, "user", accountPublicID(ctx, tx, w.UserID), "onboarding.profile.create", requestID, map[string]any{"subject_id": id}); err != nil {
return nil, err
}
return map[string]any{"elder_id": id, "household_id": public}, nil
})
}
func accountPublicID(ctx context.Context, q queryer, id string) string {
var p string
_ = q.QueryRow(ctx, `SELECT public_id::text FROM user_account WHERE id=$1::uuid`, id).Scan(&p)
return p
}