This commit is contained in:
@@ -71,7 +71,7 @@ X-Request-ID: <request id>
|
|||||||
X-Device-ID: <optional device id>
|
X-Device-ID: <optional device id>
|
||||||
```
|
```
|
||||||
|
|
||||||
默认租户通过 `OLONS_SERVER_DEFAULT_TENANT_ID` 配置,现有 `olons_admin` 数据使用 `tenant_id=0`。服务不接受客户端传入租户身份。
|
服务采用单租户模式,登录仅按手机号查找账号,不读取或接受租户环境变量。现有数据库中的 `tenant_id` 字段仅作为兼容字段保留,新注册数据固定写入 `0`。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "olons_server:session:token:%s",
|
"session_key_pattern": "olons_server:session:token:%s",
|
||||||
"session_ttl_seconds": 1209600,
|
"session_ttl_seconds": 1209600,
|
||||||
"default_tenant_id": 0,
|
|
||||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||||
"allow_dev_header_auth": true,
|
"allow_dev_header_auth": true,
|
||||||
"required_api_key": false
|
"required_api_key": false
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"api_keys": [],
|
"api_keys": [],
|
||||||
"session_key_pattern": "olons_server:session:token:%s",
|
"session_key_pattern": "olons_server:session:token:%s",
|
||||||
"session_ttl_seconds": 1209600,
|
"session_ttl_seconds": 1209600,
|
||||||
"default_tenant_id": 0,
|
|
||||||
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
"finance_master_key": "olons-finance-master-key-v1-20261013",
|
||||||
"required_api_key": true
|
"required_api_key": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
| `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 |
|
| `Authorization: Bearer <token>` | 受保护接口必填 | 登录成功后取得的用户会话 |
|
||||||
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
|
| `Idempotency-Key` | 取出提交必填 | 8–80 位请求唯一键;相同业务重试必须复用,不同请求禁止复用 |
|
||||||
|
|
||||||
`tenant_id` 由后端部署配置确定。客户端不得在 Header、Query 或 Body 中传入租户身份。
|
服务采用单租户模式,客户端不得在 Header、Query 或 Body 中传入租户身份。登录仅按手机号查找账号;数据库中的 `tenant_id` 仅作为兼容字段保留。
|
||||||
|
|
||||||
### 3.3 登录会话
|
### 3.3 登录会话
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type profileRepository struct {
|
|||||||
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
func (r *profileRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
func (r *profileRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
func (r *profileRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("not implemented")
|
||||||
}
|
}
|
||||||
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
func (r *profileRepository) RecordLogin(context.Context, int64, int64) error { return nil }
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -47,7 +46,7 @@ var (
|
|||||||
|
|
||||||
type Repository interface {
|
type Repository interface {
|
||||||
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error)
|
||||||
AccountByMobile(context.Context, int64, string) (*store.Account, error)
|
AccountByMobile(context.Context, string) (*store.Account, error)
|
||||||
RecordLogin(context.Context, int64, int64) error
|
RecordLogin(context.Context, int64, int64) error
|
||||||
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
|
MemberOverview(context.Context, int64, int64) (*store.MemberOverview, error)
|
||||||
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
|
UpdateAvatar(context.Context, int64, int64, int64) (*store.Avatar, error)
|
||||||
@@ -179,7 +178,7 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (*Result, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
account, err := s.repository.RegisterAccount(ctx, store.AccountRegistration{
|
||||||
TenantID: s.cfg.DefaultTenantID,
|
TenantID: 0,
|
||||||
MemberNo: memberNo,
|
MemberNo: memberNo,
|
||||||
Username: input.Username,
|
Username: input.Username,
|
||||||
Mobile: input.Phone,
|
Mobile: input.Phone,
|
||||||
@@ -237,7 +236,7 @@ func (s *Service) Login(ctx context.Context, input LoginInput) (*Result, error)
|
|||||||
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
if attempts, err := s.redis.Get(ctx, rateKey).Int(); err == nil && attempts >= 10 {
|
||||||
return nil, ErrRateLimited
|
return nil, ErrRateLimited
|
||||||
}
|
}
|
||||||
account, err := s.repository.AccountByMobile(ctx, s.cfg.DefaultTenantID, phone)
|
account, err := s.repository.AccountByMobile(ctx, phone)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, store.ErrInvalidCredentials) {
|
if errors.Is(err, store.ErrInvalidCredentials) {
|
||||||
s.recordLoginFailure(ctx, rateKey)
|
s.recordLoginFailure(ctx, rateKey)
|
||||||
@@ -387,7 +386,7 @@ func (s *Service) issueSession(ctx context.Context, account *store.Account, devi
|
|||||||
|
|
||||||
func (s *Service) loginRateKey(phone string) string {
|
func (s *Service) loginRateKey(phone string) string {
|
||||||
digest := sha256.Sum256([]byte(phone))
|
digest := sha256.Sum256([]byte(phone))
|
||||||
return "olons_server:auth:login-rate:" + strconv.FormatInt(s.cfg.DefaultTenantID, 10) + ":" + hex.EncodeToString(digest[:])
|
return "olons_server:auth:login-rate:" + hex.EncodeToString(digest[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
func (s *Service) recordLoginFailure(ctx context.Context, key string) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type fakeRepository struct{}
|
|||||||
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
func (fakeRepository) RegisterAccount(context.Context, store.AccountRegistration) (*store.Account, error) {
|
||||||
return nil, errors.New("should not be called")
|
return nil, errors.New("should not be called")
|
||||||
}
|
}
|
||||||
func (fakeRepository) AccountByMobile(context.Context, int64, string) (*store.Account, error) {
|
func (fakeRepository) AccountByMobile(context.Context, string) (*store.Account, error) {
|
||||||
return nil, errors.New("should not be called")
|
return nil, errors.New("should not be called")
|
||||||
}
|
}
|
||||||
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
func (fakeRepository) RecordLogin(context.Context, int64, int64) error {
|
||||||
|
|||||||
@@ -46,17 +46,13 @@ func New(cfg config.AuthConfig, redisClient *redis.Client) *Authenticator {
|
|||||||
return &Authenticator{cfg: cfg, redis: redisClient}
|
return &Authenticator{cfg: cfg, redis: redisClient}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) DefaultTenantID() int64 {
|
|
||||||
return a.cfg.DefaultTenantID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
func (a *Authenticator) Authenticate(req *http.Request) (*Principal, error) {
|
||||||
if err := a.ValidateAPIKey(req); err != nil {
|
if err := a.ValidateAPIKey(req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if a.cfg.AllowDevHeaderAuth {
|
if a.cfg.AllowDevHeaderAuth {
|
||||||
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
|
if memberID := parseInt64Header(req, "X-Member-ID"); memberID > 0 {
|
||||||
return &Principal{TenantID: a.cfg.DefaultTenantID, MemberID: memberID,
|
return &Principal{TenantID: 0, MemberID: memberID,
|
||||||
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
|
DeviceID: strings.TrimSpace(req.Header.Get("X-Device-ID"))}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ type AuthConfig struct {
|
|||||||
APIKeys []string `json:"api_keys"`
|
APIKeys []string `json:"api_keys"`
|
||||||
SessionKeyPattern string `json:"session_key_pattern"`
|
SessionKeyPattern string `json:"session_key_pattern"`
|
||||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||||
DefaultTenantID int64 `json:"default_tenant_id"`
|
|
||||||
DataEncryptionKey string `json:"data_encryption_key"`
|
DataEncryptionKey string `json:"data_encryption_key"`
|
||||||
LookupHMACKey string `json:"lookup_hmac_key"`
|
LookupHMACKey string `json:"lookup_hmac_key"`
|
||||||
FinanceMasterKey string `json:"finance_master_key"`
|
FinanceMasterKey string `json:"finance_master_key"`
|
||||||
@@ -106,7 +105,6 @@ func defaultConfig() *Config {
|
|||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
SessionKeyPattern: "olons_server:session:token:%s",
|
SessionKeyPattern: "olons_server:session:token:%s",
|
||||||
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
SessionTTLSeconds: 14 * 24 * 60 * 60,
|
||||||
DefaultTenantID: 0,
|
|
||||||
RequiredAPIKey: true,
|
RequiredAPIKey: true,
|
||||||
},
|
},
|
||||||
Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
|
Upload: UploadConfig{Driver: "local", Dir: "storage/uploads", PublicPath: "/uploads", Region: "us-east-1"},
|
||||||
@@ -133,7 +131,6 @@ func applyEnv(cfg *Config) {
|
|||||||
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
setInt(&cfg.Redis.DB, "OLONS_SERVER_REDIS_DB")
|
||||||
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
setString(&cfg.Auth.SessionKeyPattern, "OLONS_SERVER_SESSION_KEY_PATTERN")
|
||||||
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
setInt(&cfg.Auth.SessionTTLSeconds, "OLONS_SERVER_SESSION_TTL_SECONDS")
|
||||||
setInt64(&cfg.Auth.DefaultTenantID, "OLONS_SERVER_DEFAULT_TENANT_ID")
|
|
||||||
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
setString(&cfg.Auth.DataEncryptionKey, "OLONS_SERVER_DATA_ENCRYPTION_KEY")
|
||||||
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
setString(&cfg.Auth.LookupHMACKey, "OLONS_SERVER_LOOKUP_HMAC_KEY")
|
||||||
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
setString(&cfg.Auth.FinanceMasterKey, "OLONS_SERVER_FINANCE_MASTER_KEY")
|
||||||
@@ -177,9 +174,6 @@ func (c *Config) validate() error {
|
|||||||
if c.Auth.SessionTTLSeconds <= 0 {
|
if c.Auth.SessionTTLSeconds <= 0 {
|
||||||
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
|
c.Auth.SessionTTLSeconds = 14 * 24 * 60 * 60
|
||||||
}
|
}
|
||||||
if c.Auth.DefaultTenantID < 0 {
|
|
||||||
return errors.New("default tenant id must not be negative")
|
|
||||||
}
|
|
||||||
if err := ValidateUpload(c.Upload); err != nil {
|
if err := ValidateUpload(c.Upload); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -261,14 +255,6 @@ func setInt(target *int, key string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setInt64(target *int64, key string) {
|
|
||||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
|
||||||
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
||||||
*target = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setBool(target *bool, key string) {
|
func setBool(target *bool, key string) {
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||||
case "1", "true", "yes", "on":
|
case "1", "true", "yes", "on":
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ func TestDefaultConfigUsesOlonsAdmin(t *testing.T) {
|
|||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if cfg.Auth.DefaultTenantID != 0 {
|
|
||||||
t.Fatalf("DefaultTenantID = %d", cfg.Auth.DefaultTenantID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigRejectsOtherDatabase(t *testing.T) {
|
func TestConfigRejectsOtherDatabase(t *testing.T) {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func (a *API) content(w http.ResponseWriter, r *http.Request) {
|
|||||||
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
writePortalError(w, r, portal.ErrContentKeyInvalid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tenantID := a.auth.DefaultTenantID()
|
tenantID := int64(0)
|
||||||
if _, public := publicContentKeys[key]; !public {
|
if _, public := publicContentKeys[key]; !public {
|
||||||
principal, ok := a.authenticateMember(w, r)
|
principal, ok := a.authenticateMember(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -95,11 +95,11 @@ func (s *Store) RegisterAccount(ctx context.Context, input AccountRegistration)
|
|||||||
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
|
InviteCode: input.InviteCode, MemberStatus: memberStatusActive, MemberCategory: memberCategoryReal}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) AccountByMobile(ctx context.Context, tenantID int64, mobile string) (*Account, error) {
|
func (s *Store) AccountByMobile(ctx context.Context, mobile string) (*Account, error) {
|
||||||
if s == nil || s.db == nil {
|
if s == nil || s.db == nil {
|
||||||
return nil, errors.New("store is unavailable")
|
return nil, errors.New("store is unavailable")
|
||||||
}
|
}
|
||||||
return accountByMobile(ctx, s.db, tenantID, mobile)
|
return accountByMobile(ctx, s.db, mobile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
|
func (s *Store) RecordLogin(ctx context.Context, tenantID, memberID int64) error {
|
||||||
@@ -112,11 +112,11 @@ type queryer interface {
|
|||||||
GetContext(context.Context, any, string, ...any) error
|
GetContext(context.Context, any, string, ...any) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func accountByMobile(ctx context.Context, db queryer, tenantID int64, mobile string) (*Account, error) {
|
func accountByMobile(ctx context.Context, db queryer, mobile string) (*Account, error) {
|
||||||
var row Account
|
var row Account
|
||||||
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
|
err := db.GetContext(ctx, &row, `SELECT tenant_id, id AS member_id, member_no, username, mobile,
|
||||||
password_hash, invite_code, member_status, risk_status AS member_category
|
password_hash, invite_code, member_status, risk_status AS member_category
|
||||||
FROM hg_app_member WHERE tenant_id=? AND mobile=? AND deleted_at IS NULL LIMIT 1`, tenantID, mobile)
|
FROM hg_app_member WHERE mobile=? AND deleted_at IS NULL LIMIT 1`, mobile)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrInvalidCredentials
|
return nil, ErrInvalidCredentials
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type accountQueryRecorder struct {
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *accountQueryRecorder) GetContext(_ context.Context, dest any, query string, args ...any) error {
|
||||||
|
r.query = query
|
||||||
|
r.args = args
|
||||||
|
account := dest.(*Account)
|
||||||
|
*account = Account{MemberID: 9, Mobile: "13311111111", MemberStatus: memberStatusActive}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAccountByMobileDoesNotFilterTenant(t *testing.T) {
|
||||||
|
recorder := &accountQueryRecorder{}
|
||||||
|
account, err := accountByMobile(context.Background(), recorder, "13311111111")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if account.MemberID != 9 {
|
||||||
|
t.Fatalf("MemberID = %d, want 9", account.MemberID)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(recorder.query), "tenant_id=?") {
|
||||||
|
t.Fatalf("query must not filter tenant: %s", recorder.query)
|
||||||
|
}
|
||||||
|
if len(recorder.args) != 1 || recorder.args[0] != "13311111111" {
|
||||||
|
t.Fatalf("query args = %#v", recorder.args)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user