From 3b0e0f1a3ff9230c2f751655a56e53ddba767e3f Mon Sep 17 00:00:00 2001 From: MaysWind Date: Tue, 21 Oct 2025 22:41:41 +0800 Subject: [PATCH] refactored common oauth 2.0 provider and add unit tests for nextcloud oauth 2.0 data source --- pkg/auth/oauth2/common_oauth2_provider.go | 78 +++++++++++++ .../oauth2/nextcloud_oauth2_datasource.go | 105 +++++++++++++++++ .../nextcloud_oauth2_datasource_test.go | 105 +++++++++++++++++ pkg/auth/oauth2/nextcloud_oauth2_provider.go | 110 ------------------ 4 files changed, 288 insertions(+), 110 deletions(-) create mode 100644 pkg/auth/oauth2/common_oauth2_provider.go create mode 100644 pkg/auth/oauth2/nextcloud_oauth2_datasource.go create mode 100644 pkg/auth/oauth2/nextcloud_oauth2_datasource_test.go delete mode 100644 pkg/auth/oauth2/nextcloud_oauth2_provider.go diff --git a/pkg/auth/oauth2/common_oauth2_provider.go b/pkg/auth/oauth2/common_oauth2_provider.go new file mode 100644 index 00000000..3fbc1136 --- /dev/null +++ b/pkg/auth/oauth2/common_oauth2_provider.go @@ -0,0 +1,78 @@ +package oauth2 + +import ( + "io" + "net/http" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" +) + +// CommonOAuth2Provider represents common OAuth 2.0 provider +type CommonOAuth2Provider struct { + OAuth2Provider + dataSource CommonOAuth2DataSource +} + +// CommonOAuth2DataSource defines the structure of OAuth 2.0 data source +type CommonOAuth2DataSource interface { + // GetAuthUrl returns the authentication url of the data source + GetAuthUrl() string + + // GetTokenUrl returns the token url of the data source + GetTokenUrl() string + + // GetUserInfoRequest returns the user info request of the data source + GetUserInfoRequest() (*http.Request, error) + + // GetScopes returns the scopes required by the data source + GetScopes() []string + + // ParseUserInfo returns the user info by parsing the response body + ParseUserInfo(c core.Context, body []byte) (*OAuth2UserInfo, error) +} + +// GetAuthUrl returns the authentication url of the common OAuth 2.0 provider +func (p *CommonOAuth2Provider) GetAuthUrl() string { + return p.dataSource.GetAuthUrl() +} + +// GetTokenUrl returns the token url of the common OAuth 2.0 provider +func (p *CommonOAuth2Provider) GetTokenUrl() string { + return p.dataSource.GetTokenUrl() +} + +// GetScopes returns the scopes required by the common OAuth 2.0 provider +func (p *CommonOAuth2Provider) GetScopes() []string { + return p.dataSource.GetScopes() +} + +// GetUserInfo returns the user info by the common OAuth 2.0 provider +func (p *CommonOAuth2Provider) GetUserInfo(c core.Context, oauth2Client *http.Client) (*OAuth2UserInfo, error) { + req, err := p.dataSource.GetUserInfoRequest() + + if err != nil { + log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info request, because %s", err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + resp, err := oauth2Client.Do(req) + + if err != nil { + log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + + log.Debugf(c, "[common_oauth2_provider.GetUserInfo] response is %s", body) + + if resp.StatusCode != 200 { + log.Errorf(c, "[common_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode) + return nil, errs.ErrFailedToRequestRemoteApi + } + + return p.dataSource.ParseUserInfo(c, body) +} diff --git a/pkg/auth/oauth2/nextcloud_oauth2_datasource.go b/pkg/auth/oauth2/nextcloud_oauth2_datasource.go new file mode 100644 index 00000000..3a612316 --- /dev/null +++ b/pkg/auth/oauth2/nextcloud_oauth2_datasource.go @@ -0,0 +1,105 @@ +package oauth2 + +import ( + "encoding/json" + "net/http" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" + "github.com/mayswind/ezbookkeeping/pkg/log" +) + +type nextcloudUserInfoResponse struct { + OCS *struct { + Meta *struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + } `json:"meta"` + Data *struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display-name"` + } `json:"data"` + } `json:"ocs"` +} + +// NextcloudOAuth2DataSource represents Nextcloud OAuth 2.0 data source +type NextcloudOAuth2DataSource struct { + CommonOAuth2DataSource + baseUrl string +} + +// GetAuthUrl returns the authentication url of the Nextcloud data source +func (s *NextcloudOAuth2DataSource) GetAuthUrl() string { + // Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-login_redirector-authorize + return s.baseUrl + "apps/oauth2/authorize" +} + +// GetTokenUrl returns the token url of the Nextcloud data source +func (s *NextcloudOAuth2DataSource) GetTokenUrl() string { + // Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/oauth2-oauth_api-get-token + return s.baseUrl + "apps/oauth2/api/v1/token" +} + +// GetUserInfoRequest returns the user info request of the Nextcloud data source +func (s *NextcloudOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) { + // Reference: https://docs.nextcloud.com/server/stable/developer_manual/_static/openapi.html#/operations/provisioning_api-users-get-current-user + req, err := http.NewRequest("GET", s.baseUrl+"ocs/v2.php/cloud/user", nil) + + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("OCS-APIRequest", "true") + return req, nil +} + +// GetScopes returns the scopes required by the Nextcloud provider +func (p *NextcloudOAuth2DataSource) GetScopes() []string { + return []string{} +} + +func (p *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*OAuth2UserInfo, error) { + userInfoResp := &nextcloudUserInfoResponse{} + err := json.Unmarshal(body, &userInfoResp) + + if err != nil { + log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] failed to parse user info response body, because %s", err.Error()) + return nil, errs.ErrCannotRetrieveUserInfo + } + + if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil { + log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] invalid user info response body") + return nil, errs.ErrCannotRetrieveUserInfo + } + + if userInfoResp.OCS.Meta.StatusCode != 200 { + log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode) + return nil, errs.ErrCannotRetrieveUserInfo + } + + if userInfoResp.OCS.Data.ID == "" { + log.Warnf(c, "[nextcloud_oauth2_datasource.ParseUserInfo] user info id is empty") + return nil, errs.ErrCannotRetrieveUserInfo + } + + return &OAuth2UserInfo{ + UserName: userInfoResp.OCS.Data.ID, + Email: userInfoResp.OCS.Data.Email, + NickName: userInfoResp.OCS.Data.DisplayName, + }, nil +} + +// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance +func NewNextcloudOAuth2Provider(baseUrl string) OAuth2Provider { + if baseUrl[len(baseUrl)-1] != '/' { + baseUrl += "/" + } + + return &CommonOAuth2Provider{ + dataSource: &NextcloudOAuth2DataSource{ + baseUrl: baseUrl, + }, + } +} diff --git a/pkg/auth/oauth2/nextcloud_oauth2_datasource_test.go b/pkg/auth/oauth2/nextcloud_oauth2_datasource_test.go new file mode 100644 index 00000000..690f2b7f --- /dev/null +++ b/pkg/auth/oauth2/nextcloud_oauth2_datasource_test.go @@ -0,0 +1,105 @@ +package oauth2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "github.com/mayswind/ezbookkeeping/pkg/errs" +) + +func TestNewNextcloudOAuth2Provider(t *testing.T) { + datasource := NewNextcloudOAuth2Provider("https://example.com/") + assert.Equal(t, "https://example.com/apps/oauth2/authorize", datasource.GetAuthUrl()) + assert.Equal(t, "https://example.com/apps/oauth2/api/v1/token", datasource.GetTokenUrl()) + + datasource = NewNextcloudOAuth2Provider("https://example.com") + assert.Equal(t, "https://example.com/apps/oauth2/authorize", datasource.GetAuthUrl()) + assert.Equal(t, "https://example.com/apps/oauth2/api/v1/token", datasource.GetTokenUrl()) +} + +func TestNextcloudOAuth2datasource_GetUserInfoRequest(t *testing.T) { + datasource := &NextcloudOAuth2DataSource{baseUrl: "https://example.com/"} + req, err := datasource.GetUserInfoRequest() + + assert.Nil(t, err) + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "https://example.com/ocs/v2.php/cloud/user", req.URL.String()) + assert.Equal(t, "application/json", req.Header.Get("Accept")) + assert.Equal(t, "true", req.Header.Get("OCS-APIRequest")) +} + +func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) { + datasource := &NextcloudOAuth2DataSource{} + responseContent := `{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200 + }, + "data": { + "id": "user1", + "email": "user1@example.com", + "display-name": "User" + } + } + }` + info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + + assert.Nil(t, err) + assert.Equal(t, "user1", info.UserName) + assert.Equal(t, "user1@example.com", info.Email) + assert.Equal(t, "User", info.NickName) +} + +func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) { + datasource := &NextcloudOAuth2DataSource{} + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid")) + + assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) +} + +func TestNextcloudOAuth2Datasource_ParseUserInfo_MissingFields(t *testing.T) { + datasource := &NextcloudOAuth2DataSource{} + responseContent := `{"ocs": {}}` + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + + assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) +} + +func TestNextcloudOAuth2Datasource_ParseUserInfo_Non200StatusCode(t *testing.T) { + datasource := &NextcloudOAuth2DataSource{} + responseContent := `{ + "ocs": { + "meta": { + "status": "error", + "statuscode": 400 + }, + "data": {} + } + }` + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + + assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) +} + +func TestNextcloudOAuth2Datasource_ParseUserInfo_EmptyID(t *testing.T) { + datasource := &NextcloudOAuth2DataSource{} + responseContent := `{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200 + }, + "data": { + "id": "", + "email": "user1@example.com", + "display-name": "User One" + } + } + }` + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + + assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) +} diff --git a/pkg/auth/oauth2/nextcloud_oauth2_provider.go b/pkg/auth/oauth2/nextcloud_oauth2_provider.go deleted file mode 100644 index c7a56279..00000000 --- a/pkg/auth/oauth2/nextcloud_oauth2_provider.go +++ /dev/null @@ -1,110 +0,0 @@ -package oauth2 - -import ( - "encoding/json" - "io" - "net/http" - - "github.com/mayswind/ezbookkeeping/pkg/core" - "github.com/mayswind/ezbookkeeping/pkg/errs" - "github.com/mayswind/ezbookkeeping/pkg/log" -) - -type nextcloudUserInfoResponse struct { - OCS *struct { - Meta *struct { - Status string `json:"status"` - StatusCode int `json:"statuscode"` - } `json:"meta"` - Data *struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"display-name"` - } `json:"data"` - } `json:"ocs"` -} - -// NextcloudOAuth2Provider represents Nextcloud OAuth 2.0 provider -type NextcloudOAuth2Provider struct { - baseUrl string -} - -// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance -func NewNextcloudOAuth2Provider(baseUrl string) OAuth2Provider { - if baseUrl[len(baseUrl)-1] != '/' { - baseUrl += "/" - } - - return &NextcloudOAuth2Provider{ - baseUrl: baseUrl, - } -} - -// GetAuthUrl returns the authentication url of the Nextcloud provider -func (p *NextcloudOAuth2Provider) GetAuthUrl() string { - return p.baseUrl + "apps/oauth2/authorize" -} - -// GetTokenUrl returns the token url of the Nextcloud provider -func (p *NextcloudOAuth2Provider) GetTokenUrl() string { - return p.baseUrl + "apps/oauth2/api/v1/token" -} - -// GetUserInfo returns the user info by the Nextcloud provider -func (p *NextcloudOAuth2Provider) GetUserInfo(c core.Context, oauth2Client *http.Client) (*OAuth2UserInfo, error) { - url := p.baseUrl + "ocs/v2.php/cloud/user?format=json" - resp, err := oauth2Client.Get(url) - - if err != nil { - log.Errorf(c, "[nextcloud_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error()) - return nil, errs.ErrFailedToRequestRemoteApi - } - - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - - log.Debugf(c, "[nextcloud_oauth2_provider.GetUserInfo] response is %s", body) - - if resp.StatusCode != 200 { - log.Errorf(c, "[nextcloud_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode) - return nil, errs.ErrFailedToRequestRemoteApi - } - - return p.parseUserInfo(c, body) -} - -// GetScopes returns the scopes required by the Nextcloud provider -func (p *NextcloudOAuth2Provider) GetScopes() []string { - return []string{"profile", "email"} -} - -func (p *NextcloudOAuth2Provider) parseUserInfo(c core.Context, body []byte) (*OAuth2UserInfo, error) { - userInfoResp := &nextcloudUserInfoResponse{} - err := json.Unmarshal(body, &userInfoResp) - - if err != nil { - log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] failed to parse user info response body, because %s", err.Error()) - return nil, errs.ErrCannotRetrieveUserInfo - } - - if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil { - log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] invalid user info response body") - return nil, errs.ErrCannotRetrieveUserInfo - } - - if userInfoResp.OCS.Meta.StatusCode != 200 { - log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode) - return nil, errs.ErrCannotRetrieveUserInfo - } - - if userInfoResp.OCS.Data.ID == "" { - log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] user info id is empty") - return nil, errs.ErrCannotRetrieveUserInfo - } - - return &OAuth2UserInfo{ - UserName: userInfoResp.OCS.Data.ID, - Email: userInfoResp.OCS.Data.Email, - NickName: userInfoResp.OCS.Data.DisplayName, - }, nil -}