68 lines
2.8 KiB
Go
68 lines
2.8 KiB
Go
package onboarding
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/jackc/pgx/v5"
|
||
|
|
)
|
||
|
|
|
||
|
|
const trustedLoginLifetime = 30 * 24 * time.Hour
|
||
|
|
|
||
|
|
func (s *Service) loginRealm() string {
|
||
|
|
if s.operatorLogin {
|
||
|
|
return "operator"
|
||
|
|
}
|
||
|
|
return "customer"
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) trustedLoginCookieName() string {
|
||
|
|
name := "platform-" + s.loginRealm() + "-trusted-device"
|
||
|
|
if !s.options.AllowLocalHTTP {
|
||
|
|
name = "__Host-" + name
|
||
|
|
}
|
||
|
|
return name
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) trustedLoginCookie(r *http.Request) string {
|
||
|
|
if c, err := r.Cookie(s.trustedLoginCookieName()); err == nil && validB64(c.Value, 32) {
|
||
|
|
return c.Value
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// Only a successfully verified, single-use SMS can establish device trust.
|
||
|
|
// Bind it to the exact credential so an administrative password reset also
|
||
|
|
// invalidates previously trusted devices without needing a separate revocation.
|
||
|
|
func (s *Service) rememberLoginDevice(ctx context.Context, tx pgx.Tx, phoneHash string) (string, error) {
|
||
|
|
var encoded string
|
||
|
|
if err := tx.QueryRow(ctx, "SELECT COALESCE(password_hash,'') FROM "+s.phoneCredentialTable()+" WHERE phone_digest=$1 FOR SHARE", phoneHash).Scan(&encoded); err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
token := randomToken(32)
|
||
|
|
_, err := tx.Exec(ctx, `INSERT INTO trusted_login_device(token_hash,realm,phone_digest,credential_digest,verified_at,expires_at) VALUES($1,$2,$3,$4,$5,$6)`, digest([]byte(token)), s.loginRealm(), phoneHash, digest([]byte(encoded)), s.now(), s.now().Add(trustedLoginLifetime))
|
||
|
|
return token, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) requireTrustedLoginDevice(ctx context.Context, tx pgx.Tx, phoneHash, encoded, token string) error {
|
||
|
|
trusted := false
|
||
|
|
if validB64(token, 32) {
|
||
|
|
err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM trusted_login_device WHERE token_hash=$1 AND realm=$2 AND phone_digest=$3 AND credential_digest=$4 AND expires_at>$5 AND revoked_at IS NULL)`, digest([]byte(token)), s.loginRealm(), phoneHash, digest([]byte(encoded)), s.now()).Scan(&trusted)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !trusted {
|
||
|
|
return fail(403, "NEW_DEVICE_SMS_REQUIRED", "首次在此设备登录,请使用手机验证码完成验证")
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) updateTrustedDevicesAfterPassword(ctx context.Context, tx pgx.Tx, phoneHash, oldHash, newHash, currentToken string) error {
|
||
|
|
// Retain only an existing, unexpired SMS verification on this browser. A
|
||
|
|
// password change must never turn an unverified browser into a trusted one.
|
||
|
|
_, err := tx.Exec(ctx, `UPDATE trusted_login_device SET credential_digest=CASE WHEN token_hash=$3 AND credential_digest=$4 AND expires_at>$6 THEN $5 ELSE credential_digest END,revoked_at=CASE WHEN token_hash=$3 AND credential_digest=$4 AND expires_at>$6 THEN NULL ELSE $6 END WHERE realm=$1 AND phone_digest=$2 AND revoked_at IS NULL`, s.loginRealm(), phoneHash, digest([]byte(currentToken)), digest([]byte(oldHash)), digest([]byte(newHash)), s.now())
|
||
|
|
return err
|
||
|
|
}
|