176 lines
6.2 KiB
Go
176 lines
6.2 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Native clients carry an explicit credential; browser cookies and CSRF rules
|
||
|
|
// remain unchanged. Realm prefixes never grant a role or create an account.
|
||
|
|
func miniCredential(header, scheme string) (string, string, bool) {
|
||
|
|
if !strings.HasPrefix(header, scheme+" ") {
|
||
|
|
return "", "", false
|
||
|
|
}
|
||
|
|
realm, token, ok := strings.Cut(strings.TrimPrefix(header, scheme+" "), ".")
|
||
|
|
return realm, token, ok && (realm == "customer" || realm == "operator") && validB64(token, 32)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) miniPrincipal(r *http.Request) (identity.Principal, error) {
|
||
|
|
realm, token, ok := miniCredential(r.Header.Get("Authorization"), "Mini")
|
||
|
|
if !ok || (realm == "operator") != s.operatorLogin {
|
||
|
|
return identity.Principal{}, fail(401, "LOGIN_REQUIRED", "请先登录对应账号")
|
||
|
|
}
|
||
|
|
return s.AccountPrincipal(r.Context(), token, "", false)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) miniHTTP(r *http.Request, requestID string) (any, error) {
|
||
|
|
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||
|
|
return nil, errAccess
|
||
|
|
}
|
||
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/mini/")
|
||
|
|
ctx := r.Context()
|
||
|
|
if path == "login/session" {
|
||
|
|
raw, err := readBody(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var in struct {
|
||
|
|
Portal string `json:"portal"`
|
||
|
|
}
|
||
|
|
if err = StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if in.Portal != "customer" && in.Portal != "operator" {
|
||
|
|
return nil, errAccess
|
||
|
|
}
|
||
|
|
view := s
|
||
|
|
if in.Portal == "operator" {
|
||
|
|
view = s.operatorView()
|
||
|
|
}
|
||
|
|
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
||
|
|
if err = s.limit(ctx, "mini-login:"+host, 30, time.Minute); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
token := randomToken(32)
|
||
|
|
_, err = s.pool.Exec(ctx, `INSERT INTO `+view.loginSessionTable()+`(token_hash,csrf_hash,expires_at) VALUES($1,$2,$3)`, digest([]byte(token)), digest([]byte(view.sessionCSRF("LOGIN-CSRF1", token))), s.now().Add(20*time.Minute))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return map[string]any{"login_token": in.Portal + "." + token, "expires_in": 1200, "sms_enabled": s.options.SMSLoginEnabled}, nil
|
||
|
|
}
|
||
|
|
if strings.HasPrefix(path, "login/") {
|
||
|
|
realm, token, ok := miniCredential(r.Header.Get("Authorization"), "MiniLogin")
|
||
|
|
if !ok {
|
||
|
|
return nil, fail(401, "LOGIN_SESSION_EXPIRED", "请重新获取验证码")
|
||
|
|
}
|
||
|
|
view := s
|
||
|
|
if realm == "operator" {
|
||
|
|
view = s.operatorView()
|
||
|
|
}
|
||
|
|
hash := digest([]byte(token))
|
||
|
|
var live bool
|
||
|
|
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM `+view.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now())`, hash).Scan(&live); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if !live {
|
||
|
|
return nil, fail(401, "LOGIN_SESSION_EXPIRED", "请重新获取验证码")
|
||
|
|
}
|
||
|
|
raw, err := readBody(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if path == "login/captcha" {
|
||
|
|
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return view.loginCaptcha(ctx, hash)
|
||
|
|
}
|
||
|
|
if path == "login/wechat" {
|
||
|
|
return decodeCall(raw, func(in WeChatLoginInput) (map[string]any, error) { return view.weChatLogin(ctx, hash, in) })
|
||
|
|
}
|
||
|
|
if path == "login/wechat-link" {
|
||
|
|
return decodeCall(raw, func(in WeChatPasswordInput) (map[string]any, error) { return view.weChatPasswordLogin(ctx, hash, in) })
|
||
|
|
}
|
||
|
|
check := func(tx pgx.Tx) error { return view.checkLoginSession(ctx, tx, hash) }
|
||
|
|
if path == "login/sms/send" {
|
||
|
|
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) {
|
||
|
|
return view.sendSMS(ctx, hash, requestID, in, view.loginSMSTable(), check)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
if path == "login/sms/verify" {
|
||
|
|
var in SMSVerifyInput
|
||
|
|
if err = StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out, err := view.verifySMS(ctx, hash, in, view.loginSMSTable(), check, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
|
||
|
|
return view.attachCustomerLogin(ctx, tx, hash, p, true)
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return map[string]any{"access_token": realm + "." + out.AccountToken, "portal": realm, "expires_in": 86400, "trusted_device_token": out.TrustedDeviceToken, "trusted_device_expires_in": int(trustedLoginLifetime.Seconds())}, nil
|
||
|
|
}
|
||
|
|
var in PasswordLoginInput
|
||
|
|
if err = StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out, err := view.passwordLogin(ctx, hash, in)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return map[string]any{"access_token": realm + "." + out.AccountToken, "portal": realm, "expires_in": 86400}, nil
|
||
|
|
}
|
||
|
|
realm, token, ok := miniCredential(r.Header.Get("Authorization"), "Mini")
|
||
|
|
if !ok {
|
||
|
|
return nil, fail(401, "LOGIN_REQUIRED", "请先登录")
|
||
|
|
}
|
||
|
|
view := s
|
||
|
|
if realm == "operator" {
|
||
|
|
view = s.operatorView()
|
||
|
|
}
|
||
|
|
p, err := view.miniPrincipal(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if path == "account" {
|
||
|
|
return map[string]any{"authenticated": true, "portal": realm, "display_name": p.DisplayName}, nil
|
||
|
|
}
|
||
|
|
raw, err := readBody(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if path == "logout" {
|
||
|
|
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
_, err = s.pool.Exec(ctx, `UPDATE `+view.accountSessionTable()+` SET revoked_at=COALESCE(revoked_at,now()) WHERE token_hash=$1`, digest([]byte(token)))
|
||
|
|
return map[string]any{"signed_out": true}, err
|
||
|
|
}
|
||
|
|
if realm != "customer" {
|
||
|
|
return nil, fail(403, "CUSTOMER_REQUIRED", "请使用家庭账号绑定设备")
|
||
|
|
}
|
||
|
|
if err = s.limit(ctx, "mini-ble:"+p.Issuer+":"+p.Subject, 90, time.Minute); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
switch path {
|
||
|
|
case "device-subject":
|
||
|
|
return decodeCall(raw, func(in AssignDeviceSubjectInput) (map[string]any, error) {
|
||
|
|
return s.AssignDeviceSubject(ctx, p, r.Header.Get("X-Request-ID"), in)
|
||
|
|
})
|
||
|
|
case "ble-device-state":
|
||
|
|
return decodeCall(raw, func(in BLEDeviceInput) (map[string]any, error) { return s.BLEDeviceState(ctx, p, in) })
|
||
|
|
case "ble-session":
|
||
|
|
return decodeCall(raw, func(in BLESessionInput) (map[string]any, error) { return s.BLESession(ctx, p, in) })
|
||
|
|
case "ble-bind-tokens":
|
||
|
|
return decodeCall(raw, func(in BLETokenInput) (map[string]any, error) { return s.CreateBLEToken(ctx, p, in) })
|
||
|
|
case "ble-bind-status":
|
||
|
|
return decodeCall(raw, func(in BLEBindInput) (map[string]any, error) { return s.BLEStatus(ctx, p, in) })
|
||
|
|
}
|
||
|
|
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||
|
|
}
|