From 0bbf818f354c59b1d0445e9adf4183bb1ff9aa0e Mon Sep 17 00:00:00 2001 From: Asai Neko Date: Mon, 4 May 2026 01:42:27 +0800 Subject: [PATCH 1/2] Remove kyc service impl states Signed-off-by: Asai Neko --- api/kyc/query.go | 4 +- internal/kyc/passport_session_store.go | 73 ++++++++++ service/service_kyc/kyc_test.go | 162 ++++++++++++++++++++- service/service_kyc/query.go | 192 +++++++++++++++++++++---- service/service_kyc/service.go | 5 +- service/service_kyc/session.go | 26 +++- 6 files changed, 420 insertions(+), 42 deletions(-) create mode 100644 internal/kyc/passport_session_store.go diff --git a/api/kyc/query.go b/api/kyc/query.go index 136a9e9..93660a8 100644 --- a/api/kyc/query.go +++ b/api/kyc/query.go @@ -33,7 +33,7 @@ func (self *KycHandler) Query(c *gin.Context) { ctx = exception.ContextWithEndpoint(ctx, exception.EndpointKycQuery) ctx = exception.ContextWithService(ctx, exception.ServiceEndpoint) - _, ok := c.Get("user_id") + userIdOrig, ok := c.Get("user_id") if !ok { errorCode := exception.New( exception.WithStatus(exception.StatusUser), @@ -57,6 +57,8 @@ func (self *KycHandler) Query(c *gin.Context) { return } + queryData.UserId = userIdOrig.(string) + queryPayload := &service_kyc.KycQueryPayload{ Context: ctx, Data: &queryData, diff --git a/internal/kyc/passport_session_store.go b/internal/kyc/passport_session_store.go new file mode 100644 index 0000000..3ae7f83 --- /dev/null +++ b/internal/kyc/passport_session_store.go @@ -0,0 +1,73 @@ +package kyc + +import ( + "context" + "errors" + "nixcn-cms/data" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +const passportSessionTTL = 30 * time.Minute + +type PassportSessionBinding struct { + SessionId int + PassportId string +} + +func passportSessionKey(kycId uuid.UUID) string { + return "kyc:passport:" + kycId.String() +} + +func SavePassportSession(ctx context.Context, kycId uuid.UUID, b PassportSessionBinding) error { + if data.Redis == nil { + return errors.New("redis not initialized") + } + key := passportSessionKey(kycId) + + pipe := data.Redis.TxPipeline() + pipe.HSet(ctx, key, map[string]any{ + "session_id": strconv.Itoa(b.SessionId), + "passport_id": b.PassportId, + }) + pipe.Expire(ctx, key, passportSessionTTL) + _, err := pipe.Exec(ctx) + return err +} + +func LoadPassportSession(ctx context.Context, kycId uuid.UUID) (*PassportSessionBinding, error) { + if data.Redis == nil { + return nil, errors.New("redis not initialized") + } + key := passportSessionKey(kycId) + + m, err := data.Redis.HGetAll(ctx, key).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, nil + } + return nil, err + } + if len(m) == 0 { + return nil, nil + } + + sessionId, err := strconv.Atoi(m["session_id"]) + if err != nil { + return nil, err + } + return &PassportSessionBinding{ + SessionId: sessionId, + PassportId: m["passport_id"], + }, nil +} + +func DeletePassportSession(ctx context.Context, kycId uuid.UUID) error { + if data.Redis == nil { + return errors.New("redis not initialized") + } + return data.Redis.Del(ctx, passportSessionKey(kycId)).Err() +} diff --git a/service/service_kyc/kyc_test.go b/service/service_kyc/kyc_test.go index b8f77e5..fcf27df 100644 --- a/service/service_kyc/kyc_test.go +++ b/service/service_kyc/kyc_test.go @@ -8,9 +8,12 @@ import ( "net/http/httptest" "testing" + "github.com/google/uuid" "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "nixcn-cms/data" + "nixcn-cms/internal/kyc" "nixcn-cms/testutil" ) @@ -18,6 +21,29 @@ func newKycSvc() KycService { return NewKycService() } +// seedPassportKyc creates a passport-typed Kyc row and writes a corresponding +// passport-session binding to Redis. Returns the kyc_id and owning user_id so +// query tests can target a real row + Redis entry with a known SessionId. +func seedPassportKyc(t *testing.T, sessionId int) (kycIdStr string, userIdStr string) { + t.Helper() + ctx := context.Background() + + userId := uuid.New() + kycRow := data.NewKyc( + data.WithKycType("passport"), + data.WithKycUserId(userId), + ) + kycId, err := kycRow.Create(ctx) + require.NoError(t, err) + + require.NoError(t, kyc.SavePassportSession(ctx, kycId, kyc.PassportSessionBinding{ + SessionId: sessionId, + PassportId: "P12345678", + })) + + return kycId.String(), userId.String() +} + // ---- SessionKyc input-validation paths (no external API required) ---- func TestSessionKycInvalidBase64(t *testing.T) { @@ -93,10 +119,12 @@ func TestQueryKycExternalAPIError(t *testing.T) { testutil.Setup(t) // kyc.passport_reader_endpoint is unset, so GetSessionState will fail. + kycId, userId := seedPassportKyc(t, 1) + svc := newKycSvc() result := svc.QueryKyc(&KycQueryPayload{ Context: context.Background(), - Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, + Data: &KycQueryData{KycId: kycId, UserId: userId}, }) assert.Equal(t, 400, result.Common.HttpCode) @@ -115,10 +143,12 @@ func TestQueryKycUnknownSessionState(t *testing.T) { t.Cleanup(srv.Close) viper.Set("kyc.passport_reader_endpoint", srv.URL) - svc := &KycServiceImpl{PassportReaderSessionId: 1} + kycId, userId := seedPassportKyc(t, 1) + + svc := newKycSvc() result := svc.QueryKyc(&KycQueryPayload{ Context: context.Background(), - Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, + Data: &KycQueryData{KycId: kycId, UserId: userId}, }) require.NotNil(t, result, "result must never be nil") @@ -142,10 +172,12 @@ func TestQueryKycPendingStates(t *testing.T) { t.Cleanup(srv.Close) viper.Set("kyc.passport_reader_endpoint", srv.URL) - svc := &KycServiceImpl{PassportReaderSessionId: 1} + kycId, userId := seedPassportKyc(t, 1) + + svc := newKycSvc() result := svc.QueryKyc(&KycQueryPayload{ Context: context.Background(), - Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, + Data: &KycQueryData{KycId: kycId, UserId: userId}, }) require.NotNil(t, result) @@ -173,10 +205,12 @@ func TestQueryKycFailedStates(t *testing.T) { t.Cleanup(srv.Close) viper.Set("kyc.passport_reader_endpoint", srv.URL) - svc := &KycServiceImpl{PassportReaderSessionId: 1} + kycId, userId := seedPassportKyc(t, 1) + + svc := newKycSvc() result := svc.QueryKyc(&KycQueryPayload{ Context: context.Background(), - Data: &KycQueryData{KycId: "00000000-0000-0000-0000-000000000001"}, + Data: &KycQueryData{KycId: kycId, UserId: userId}, }) require.NotNil(t, result) @@ -186,3 +220,117 @@ func TestQueryKycFailedStates(t *testing.T) { }) } } + +// ---- New regression tests ---- + +// TestQueryKycRejectsForeignKycId verifies that a logged-in user cannot query +// another user's KYC by passing their kyc_id. +func TestQueryKycRejectsForeignKycId(t *testing.T) { + testutil.Setup(t) + + kycId, _ := seedPassportKyc(t, 1) + + otherUserId := uuid.New().String() + + svc := newKycSvc() + result := svc.QueryKyc(&KycQueryPayload{ + Context: context.Background(), + Data: &KycQueryData{KycId: kycId, UserId: otherUserId}, + }) + + require.NotNil(t, result) + assert.Equal(t, 403, result.Common.HttpCode) +} + +// TestQueryKycRejectsUnknownKycId verifies a non-existent kyc_id returns 404 +// and never falls through to any external session lookup. +func TestQueryKycRejectsUnknownKycId(t *testing.T) { + testutil.Setup(t) + + svc := newKycSvc() + result := svc.QueryKyc(&KycQueryPayload{ + Context: context.Background(), + Data: &KycQueryData{ + KycId: uuid.New().String(), + UserId: uuid.New().String(), + }, + }) + + require.NotNil(t, result) + assert.Equal(t, 404, result.Common.HttpCode) +} + +// TestQueryKycMissingBinding verifies that when the Redis binding has expired +// or is missing, the query reports a clear 400 instead of using stale state. +func TestQueryKycMissingBinding(t *testing.T) { + testutil.Setup(t) + + ctx := context.Background() + userId := uuid.New() + kycRow := data.NewKyc( + data.WithKycType("passport"), + data.WithKycUserId(userId), + ) + kycId, err := kycRow.Create(ctx) + require.NoError(t, err) + + svc := newKycSvc() + result := svc.QueryKyc(&KycQueryPayload{ + Context: ctx, + Data: &KycQueryData{ + KycId: kycId.String(), + UserId: userId.String(), + }, + }) + + require.NotNil(t, result) + assert.Equal(t, 400, result.Common.HttpCode) +} + +// TestQueryKycPerKycSessionIsolation simulates two users who created passport +// sessions sequentially. Each must see their own session state, regardless of +// which session was created last. +func TestQueryKycPerKycSessionIsolation(t *testing.T) { + testutil.Setup(t) + + // Mock returns different states based on the requested session id. + mux := http.NewServeMux() + mux.HandleFunc("/session.state", func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID int `json:"id"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + state := "CREATED" + if req.ID == 200 { + state = "FAILED" + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"state": state}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + viper.Set("kyc.passport_reader_endpoint", srv.URL) + + kycA, userA := seedPassportKyc(t, 100) + kycB, userB := seedPassportKyc(t, 200) + + svc := newKycSvc() + + resA := svc.QueryKyc(&KycQueryPayload{ + Context: context.Background(), + Data: &KycQueryData{KycId: kycA, UserId: userA}, + }) + require.NotNil(t, resA) + require.Equal(t, 200, resA.Common.HttpCode) + require.NotNil(t, resA.Data) + assert.Equal(t, "pending", resA.Data.Status) + + resB := svc.QueryKyc(&KycQueryPayload{ + Context: context.Background(), + Data: &KycQueryData{KycId: kycB, UserId: userB}, + }) + require.NotNil(t, resB) + require.Equal(t, 200, resB.Common.HttpCode) + require.NotNil(t, resB.Data) + assert.Equal(t, "failed", resB.Data.Status) +} diff --git a/service/service_kyc/query.go b/service/service_kyc/query.go index 2c266bc..6f33258 100644 --- a/service/service_kyc/query.go +++ b/service/service_kyc/query.go @@ -13,7 +13,8 @@ import ( ) type KycQueryData struct { - KycId string `json:"kyc_id" validate:"required"` + KycId string `json:"kyc_id" validate:"required"` + UserId string `json:"user_id" swaggerignore:"true"` } type KycQueryPayload struct { @@ -40,9 +41,165 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery ctx = exception.ContextWithService(ctx, exception.ServiceKycQuery) - var err error + kycId, err := uuid.Parse(payload.Data.KycId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx) - sessionState, err := kyc.GetSessionState(ctx, self.PassportReaderSessionId) + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 400, + Exception: exc, + }, + Data: nil, + } + + return + } + + userId, err := uuid.Parse(payload.Data.UserId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorUuidParseFailed), + exception.WithError(err), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 400, + Exception: exc, + }, + Data: nil, + } + + return + } + + kycRow, err := new(data.Kyc).GetByKycId(ctx, &kycId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorDatabase), + exception.WithError(err), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 500, + Exception: exc, + }, + Data: nil, + } + + return + } + + if kycRow == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("kyc not found")), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 404, + Exception: exc, + }, + Data: nil, + } + + return + } + + if kycRow.UserId != userId { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorPermissionDenied), + exception.WithError(errors.New("kyc does not belong to caller")), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 403, + Exception: exc, + }, + Data: nil, + } + + return + } + + if kycRow.Type != "passport" { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("kyc is not a passport flow")), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 400, + Exception: exc, + }, + Data: nil, + } + + return + } + + // Per-flow passport state lives in Redis under a key derived from kyc_id, + // so concurrent passport sessions cannot read each other's session id. + binding, err := kyc.LoadPassportSession(ctx, kycId) + if err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInternal), + exception.WithError(err), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 500, + Exception: exc, + }, + Data: nil, + } + + return + } + + if binding == nil { + exc := exception.New( + exception.WithStatus(exception.StatusUser), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInvalidInput), + exception.WithError(errors.New("passport session expired or not found")), + ).Throw(ctx) + + result = &KycQueryResult{ + Common: shared.CommonResult{ + HttpCode: 400, + Exception: exc, + }, + Data: nil, + } + + return + } + + sessionState, err := kyc.GetSessionState(ctx, binding.SessionId) if err != nil { exc := exception.New( exception.WithStatus(exception.StatusUser), @@ -63,7 +220,7 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery } if sessionState == kyc.StateApproved { - sessionDetails, err := kyc.GetSessionDetails(ctx, self.PassportReaderSessionId) + sessionDetails, err := kyc.GetSessionDetails(ctx, binding.SessionId) if err != nil { exc := exception.New( exception.WithStatus(exception.StatusUser), @@ -93,12 +250,12 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery ExpiryDate: sessionDetails.ExpiryDate, } - if kycInfo.DocumentType != "PASSPORT" || kycInfo.DocumentNumber != self.PassportId { + if kycInfo.DocumentType != "PASSPORT" || kycInfo.DocumentNumber != binding.PassportId { exc := exception.New( exception.WithStatus(exception.StatusUser), exception.WithType(exception.TypeCommon), exception.WithOriginal(exception.CommonErrorInvalidInput), - exception.WithError(err), + exception.WithError(errors.New("passport document mismatch")), ).Throw(ctx) result = &KycQueryResult{ @@ -130,26 +287,6 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery return } - kycId, err := uuid.Parse(payload.Data.KycId) - if err != nil { - exc := exception.New( - exception.WithStatus(exception.StatusUser), - exception.WithType(exception.TypeCommon), - exception.WithOriginal(exception.CommonErrorUuidParseFailed), - exception.WithError(err), - ).Throw(ctx) - - result = &KycQueryResult{ - Common: shared.CommonResult{ - HttpCode: 400, - Exception: exc, - }, - Data: nil, - } - - return - } - err = new(data.Kyc). PatchByKycId(ctx, &kycId, data.WithKycInfo(*encodedKycInfo)) if err != nil { @@ -171,6 +308,9 @@ func (self *KycServiceImpl) QueryKyc(payload *KycQueryPayload) (result *KycQuery return } + // Binding is no longer needed; best-effort cleanup. + _ = kyc.DeletePassportSession(ctx, kycId) + exc := exception.New( exception.WithStatus(exception.StatusSuccess), exception.WithType(exception.TypeCommon), diff --git a/service/service_kyc/service.go b/service/service_kyc/service.go index 4770111..7ce504c 100644 --- a/service/service_kyc/service.go +++ b/service/service_kyc/service.go @@ -5,10 +5,7 @@ type KycService interface { QueryKyc(*KycQueryPayload) *KycQueryResult } -type KycServiceImpl struct { - PassportId string `json:"passport_id"` - PassportReaderSessionId int `json:"passport_reader_session_id"` -} +type KycServiceImpl struct{} func NewKycService() KycService { return &KycServiceImpl{} diff --git a/service/service_kyc/session.go b/service/service_kyc/session.go index 5a915da..4952ee0 100644 --- a/service/service_kyc/session.go +++ b/service/service_kyc/session.go @@ -262,8 +262,6 @@ func (self *KycServiceImpl) SessionKyc(payload *KycSessionPayload) (result *KycS return } - self.PassportId = info.ID - sessionResponse, err := kyc.CreateSession(ctx) if err != nil { exc := exception.New( @@ -284,8 +282,6 @@ func (self *KycServiceImpl) SessionKyc(payload *KycSessionPayload) (result *KycS return } - self.PassportReaderSessionId = sessionResponse.ID - userId, err := uuid.Parse(payload.Data.UserId) if err != nil { exc := exception.New( @@ -329,6 +325,28 @@ func (self *KycServiceImpl) SessionKyc(payload *KycSessionPayload) (result *KycS return } + if err := kyc.SavePassportSession(ctx, kycIdOrig, kyc.PassportSessionBinding{ + SessionId: sessionResponse.ID, + PassportId: info.ID, + }); err != nil { + exc := exception.New( + exception.WithStatus(exception.StatusServer), + exception.WithType(exception.TypeCommon), + exception.WithOriginal(exception.CommonErrorInternal), + exception.WithError(err), + ).Throw(ctx) + + result = &KycSessionResult{ + Common: shared.CommonResult{ + HttpCode: 500, + Exception: exc, + }, + Data: nil, + } + + return + } + kycId := kycIdOrig.String() kycBaseURL := "https://passportreader.app/open" kycUrl, _ := url.Parse(kycBaseURL) -- 2.49.1 From fb087986f0d482a77cb19d5c760162af7497892c Mon Sep 17 00:00:00 2001 From: Asai Neko Date: Mon, 4 May 2026 01:45:18 +0800 Subject: [PATCH 2/2] Fix event join service attendanceSearch check GetAttendance func order Signed-off-by: Asai Neko --- service/service_event/join.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/service/service_event/join.go b/service/service_event/join.go index 3114a1f..d3acc77 100644 --- a/service/service_event/join.go +++ b/service/service_event/join.go @@ -164,7 +164,7 @@ func (self *EventServiceImpl) Join(payload *EventJoinPayload) (result *EventJoin return } - attendenceSearch, err := new(data.Attendance).GetAttendance(ctx, eventId, userId) + attendenceSearch, err := new(data.Attendance).GetAttendance(ctx, userId, eventId) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { exc := exception.New( -- 2.49.1