180 lines
7.5 KiB
Go
180 lines
7.5 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/sha256"
|
||
|
|
"errors"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"data-terminal-cloud/internal/identity"
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (s *Service) loginCookieName() string {
|
||
|
|
if s.operatorLogin {
|
||
|
|
if s.options.AllowLocalHTTP {
|
||
|
|
return "platform-operator-login"
|
||
|
|
}
|
||
|
|
return "__Host-platform-operator-login"
|
||
|
|
}
|
||
|
|
if s.options.AllowLocalHTTP {
|
||
|
|
return "platform-login"
|
||
|
|
}
|
||
|
|
return "__Host-platform-login"
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stable for one cookie, so loading another tab does not invalidate its CSRF token.
|
||
|
|
func (s *Service) sessionCSRF(domain, token string) string {
|
||
|
|
m := hmac.New(sha256.New, s.crypt.hashKey)
|
||
|
|
m.Write([]byte(domain + "\n" + token))
|
||
|
|
return encode64(m.Sum(nil))
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) customerLoginHTTP(w http.ResponseWriter, r *http.Request, requestID string) (any, error) {
|
||
|
|
if r.URL.RawQuery != "" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
|
||
|
|
return nil, errAccess
|
||
|
|
}
|
||
|
|
if r.Method == http.MethodGet {
|
||
|
|
token := ""
|
||
|
|
if c, err := r.Cookie(s.loginCookieName()); err == nil && validB64(c.Value, 32) {
|
||
|
|
var live bool
|
||
|
|
if err = s.pool.QueryRow(r.Context(), `SELECT EXISTS(SELECT 1 FROM `+s.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL AND expires_at>now())`, digest([]byte(c.Value))).Scan(&live); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if live {
|
||
|
|
token = c.Value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if token == "" {
|
||
|
|
token = randomToken(32)
|
||
|
|
_, err := s.pool.Exec(r.Context(), `INSERT INTO `+s.loginSessionTable()+`(token_hash,csrf_hash,expires_at) VALUES($1,$2,$3)`, digest([]byte(token)), digest([]byte(s.sessionCSRF("LOGIN-CSRF1", token))), s.now().Add(20*time.Minute))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
http.SetCookie(w, &http.Cookie{Name: s.loginCookieName(), Value: token, Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: 1200})
|
||
|
|
}
|
||
|
|
return map[string]any{"sms_enabled": s.options.SMSLoginEnabled, "password_enabled": true, "signup_enabled": !s.operatorLogin && len(s.options.SelfSignupTenantIDs) == 1, "csrf_token": s.sessionCSRF("LOGIN-CSRF1", token)}, nil
|
||
|
|
}
|
||
|
|
if !s.sameOrigin(r) {
|
||
|
|
return nil, errAccess
|
||
|
|
}
|
||
|
|
cookie, err := r.Cookie(s.loginCookieName())
|
||
|
|
if err != nil || !validB64(cookie.Value, 32) {
|
||
|
|
return nil, fail(401, "LOGIN_SESSION_EXPIRED", "登录页面已过期,请刷新后重新获取验证码")
|
||
|
|
}
|
||
|
|
if !hmac.Equal([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.sessionCSRF("LOGIN-CSRF1", cookie.Value))) {
|
||
|
|
return nil, errAccess
|
||
|
|
}
|
||
|
|
hash := digest([]byte(cookie.Value))
|
||
|
|
raw, err := readBody(r)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
check := func(tx pgx.Tx) error { return s.checkLoginSession(r.Context(), tx, hash) }
|
||
|
|
switch {
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/captcha/verify"):
|
||
|
|
return decodeCall(raw, func(in SlideCaptchaVerifyInput) (map[string]any, error) {
|
||
|
|
return s.verifySlideCaptcha(r.Context(), hash, in, check)
|
||
|
|
})
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/captcha"):
|
||
|
|
if err = StrictJSON(raw, &struct{}{}); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return s.loginCaptcha(r.Context(), hash)
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/password"):
|
||
|
|
var in PasswordLoginInput
|
||
|
|
if err = StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
// Browser trust comes exclusively from its HttpOnly cookie.
|
||
|
|
in.TrustedDeviceToken = s.trustedLoginCookie(r)
|
||
|
|
out, err := s.passwordLogin(r.Context(), hash, in)
|
||
|
|
if err == nil {
|
||
|
|
s.completeCustomerLogin(w, out)
|
||
|
|
}
|
||
|
|
return out, err
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/sms/send"):
|
||
|
|
return decodeCall(raw, func(in SMSSendInput) (map[string]any, error) {
|
||
|
|
return s.sendSMS(r.Context(), hash, requestID, in, s.loginSMSTable(), check)
|
||
|
|
})
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/sms/verify"):
|
||
|
|
var in SMSVerifyInput
|
||
|
|
if err = StrictJSON(raw, &in); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out, err := s.verifySMS(r.Context(), hash, in, s.loginSMSTable(), check, func(tx pgx.Tx, p identity.Principal) (BrowserCredentials, error) {
|
||
|
|
return s.attachCustomerLogin(r.Context(), tx, hash, p, true)
|
||
|
|
})
|
||
|
|
if err == nil {
|
||
|
|
s.completeCustomerLogin(w, out)
|
||
|
|
}
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
return nil, fail(404, "NOT_FOUND", "接口不存在")
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) checkLoginSession(ctx context.Context, tx pgx.Tx, hash string) error {
|
||
|
|
var expires time.Time
|
||
|
|
err := tx.QueryRow(ctx, `SELECT expires_at FROM `+s.loginSessionTable()+` WHERE token_hash=$1 AND consumed_at IS NULL FOR UPDATE`, hash).Scan(&expires)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) || (err == nil && !expires.After(s.now())) {
|
||
|
|
return fail(401, "LOGIN_SESSION_EXPIRED", "登录页面已过期,请刷新后重新获取验证码")
|
||
|
|
}
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) completeCustomerLogin(w http.ResponseWriter, out BrowserCredentials) {
|
||
|
|
s.issueCookies(w, out)
|
||
|
|
http.SetCookie(w, &http.Cookie{Name: s.loginCookieName(), Value: "", Path: "/", HttpOnly: true, Secure: !s.options.AllowLocalHTTP, SameSite: http.SameSiteLaxMode, MaxAge: -1})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) attachCustomerLogin(ctx context.Context, tx pgx.Tx, hash string, p identity.Principal, allowSignup bool) (BrowserCredentials, error) {
|
||
|
|
var out BrowserCredentials
|
||
|
|
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "customer-sms:"+p.Subject); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
var user string
|
||
|
|
err := tx.QueryRow(ctx, `SELECT ua.id::text FROM user_account ua JOIN tenant t ON t.id=ua.tenant_id AND t.status='active' WHERE ua.oidc_issuer=$1 AND ua.oidc_subject=$2 AND ua.status='active' AND `+s.accountPredicate()+` ORDER BY ua.created_at,ua.id LIMIT 1 FOR SHARE OF ua,t`, p.Issuer, p.Subject).Scan(&user)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
if !allowSignup || s.operatorLogin {
|
||
|
|
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前账号暂不可用,请联系管理员")
|
||
|
|
}
|
||
|
|
// A disabled identity must not silently become a new account elsewhere.
|
||
|
|
var exists bool
|
||
|
|
if err = tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_account WHERE oidc_issuer=$1 AND oidc_subject=$2)`, p.Issuer, p.Subject).Scan(&exists); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
if exists {
|
||
|
|
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前账号暂不可用,请联系管理员")
|
||
|
|
}
|
||
|
|
// Signup tenant comes only from deployment configuration, never request JSON.
|
||
|
|
if len(s.options.SelfSignupTenantIDs) != 1 {
|
||
|
|
return out, fail(403, "SMS_ACCOUNT_NOT_REGISTERED", "此手机号尚未开通,请先通过设备绑定入口注册或联系管理员")
|
||
|
|
}
|
||
|
|
var tenant string
|
||
|
|
err = tx.QueryRow(ctx, `SELECT id::text FROM tenant WHERE public_id=$1::uuid AND status='active' FOR SHARE`, s.options.SelfSignupTenantIDs[0]).Scan(&tenant)
|
||
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
||
|
|
return out, fail(403, "ACCOUNT_UNAVAILABLE", "当前服务暂未开放注册")
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
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`, tenant, p.Issuer, p.Subject, p.DisplayName).Scan(&user)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
out.AccountToken = randomToken(32)
|
||
|
|
out.CSRF = s.sessionCSRF("ACCOUNT-CSRF1", out.AccountToken)
|
||
|
|
if _, err = tx.Exec(ctx, `INSERT INTO `+s.accountSessionTable()+`(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)); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
if _, err = tx.Exec(ctx, `UPDATE `+s.loginSessionTable()+` SET consumed_at=now() WHERE token_hash=$1`, hash); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
out.Data = map[string]any{"authenticated": true}
|
||
|
|
return out, nil
|
||
|
|
}
|