From beea6fe733223f6f0f4b6c2f930114307fb873ff Mon Sep 17 00:00:00 2001 From: MaysWind Date: Fri, 24 Oct 2025 01:45:16 +0800 Subject: [PATCH] retrieve user email address via the GitHub user email API when logging in with GitHub --- .../provider/common/common_oauth2_provider.go | 4 +- .../provider/gitea/gitea_oauth2_datasource.go | 2 +- .../gitea/gitea_oauth2_datasource_test.go | 7 +- .../github/github_oauth2_datasource.go | 193 ++++++++++++++---- .../github/github_oauth2_datasource_test.go | 56 ++--- .../nextcloud/nextcloud_oauth2_datasource.go | 2 +- .../nextcloud_oauth2_datasource_test.go | 11 +- 7 files changed, 193 insertions(+), 82 deletions(-) diff --git a/pkg/auth/oauth2/provider/common/common_oauth2_provider.go b/pkg/auth/oauth2/provider/common/common_oauth2_provider.go index aaf331ff..4cc4ca00 100644 --- a/pkg/auth/oauth2/provider/common/common_oauth2_provider.go +++ b/pkg/auth/oauth2/provider/common/common_oauth2_provider.go @@ -36,7 +36,7 @@ type CommonOAuth2DataSource interface { GetScopes() []string // ParseUserInfo returns the user info by parsing the response body - ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) + ParseUserInfo(c core.Context, body []byte, oauth2Client *http.Client) (*data.OAuth2UserInfo, error) } // GetOAuth2AuthUrl returns the authentication url of the common OAuth 2.0 provider @@ -76,7 +76,7 @@ func (p *CommonOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.T return nil, errs.ErrFailedToRequestRemoteApi } - return p.dataSource.ParseUserInfo(c, body) + return p.dataSource.ParseUserInfo(c, body, oauth2Client) } // GetDataSource returns the data source of the common OAuth 2.0 provider diff --git a/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource.go b/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource.go index 42268908..9e23d7b9 100644 --- a/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource.go +++ b/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource.go @@ -56,7 +56,7 @@ func (s *GiteaOAuth2DataSource) GetScopes() []string { } // ParseUserInfo returns the user info by parsing the response body -func (s *GiteaOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) { +func (s *GiteaOAuth2DataSource) ParseUserInfo(c core.Context, body []byte, oauth2Client *http.Client) (*data.OAuth2UserInfo, error) { userInfoResp := &giteaUserInfoResponse{} err := json.Unmarshal(body, &userInfoResp) diff --git a/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource_test.go b/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource_test.go index e46060a9..b331d9f3 100644 --- a/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource_test.go +++ b/pkg/auth/oauth2/provider/gitea/gitea_oauth2_datasource_test.go @@ -1,6 +1,7 @@ package gitea import ( + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -47,7 +48,7 @@ func TestGiteaOAuth2Datasource_ParseUserInfo_Success(t *testing.T) { "full_name": "User", "email": "user1@example.com" }` - info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{}) assert.Nil(t, err) assert.Equal(t, "user1", info.UserName) @@ -57,7 +58,7 @@ func TestGiteaOAuth2Datasource_ParseUserInfo_Success(t *testing.T) { func TestGiteaOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) { datasource := &GiteaOAuth2DataSource{} - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid")) + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"), &http.Client{}) assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) } @@ -65,7 +66,7 @@ func TestGiteaOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) { func TestGiteaOAuth2Datasource_ParseUserInfo_EmptyLogin(t *testing.T) { datasource := &GiteaOAuth2DataSource{} responseContent := `{"login": ""}` - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{}) assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) } diff --git a/pkg/auth/oauth2/provider/github/github_oauth2_datasource.go b/pkg/auth/oauth2/provider/github/github_oauth2_datasource.go index d16e8026..7f7c6c18 100644 --- a/pkg/auth/oauth2/provider/github/github_oauth2_datasource.go +++ b/pkg/auth/oauth2/provider/github/github_oauth2_datasource.go @@ -2,44 +2,163 @@ package github import ( "encoding/json" + "io" "net/http" + "golang.org/x/oauth2" + "github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/data" "github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider" - "github.com/mayswind/ezbookkeeping/pkg/auth/oauth2/provider/common" "github.com/mayswind/ezbookkeeping/pkg/core" "github.com/mayswind/ezbookkeeping/pkg/errs" "github.com/mayswind/ezbookkeeping/pkg/log" "github.com/mayswind/ezbookkeeping/pkg/settings" ) +const githubOAuth2AuthUrl = "https://github.com/login/oauth/authorize" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps +const githubOAuth2TokenUrl = "https://github.com/login/oauth/access_token" // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps +const githubUserProfileApiUrl = "https://api.github.com/user" // Reference: https://docs.github.com/en/rest/users/users +const githubUserEmailApiUrl = "https://api.github.com/user/emails" // Reference: https://docs.github.com/en/rest/users/emails + +var githubOAuth2Scopes = []string{"user:email"} + type githubUserProfileResponse struct { Login string `json:"login"` Name string `json:"name"` Email string `json:"email"` } -// GithubOAuth2DataSource represents Github OAuth 2.0 data source -type GithubOAuth2DataSource struct { - common.CommonOAuth2DataSource +type githubUserEmailsResponse struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` } -// GetAuthUrl returns the authentication url of the Github data source -func (s *GithubOAuth2DataSource) GetAuthUrl() string { - // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps - return "https://github.com/login/oauth/authorize" +// GithubOAuth2Provider represents Github OAuth 2.0 provider +type GithubOAuth2Provider struct { + provider.OAuth2Provider + oauth2Config *oauth2.Config } -// GetTokenUrl returns the token url of the Github data source -func (s *GithubOAuth2DataSource) GetTokenUrl() string { - // Reference: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps - return "https://github.com/login/oauth/access_token" +// GetOAuth2AuthUrl returns the authentication url of the GitHub OAuth 2.0 provider +func (p *GithubOAuth2Provider) GetOAuth2AuthUrl(c core.Context, state string, challenge string) (string, error) { + return p.oauth2Config.AuthCodeURL(state), nil } -// GetUserInfoRequest returns the user info request of the Github data source -func (s *GithubOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) { - // Reference: https://docs.github.com/en/rest/users/users - req, err := http.NewRequest("GET", "https://api.github.com/user", nil) +// GetOAuth2Token returns the OAuth 2.0 token of the GitHub OAuth 2.0 provider +func (p *GithubOAuth2Provider) GetOAuth2Token(c core.Context, code string, verifier string) (*oauth2.Token, error) { + return p.oauth2Config.Exchange(c, code) +} + +// GetUserInfo returns the user info by the Github OAuth 2.0 provider +func (p *GithubOAuth2Provider) GetUserInfo(c core.Context, oauth2Token *oauth2.Token) (*data.OAuth2UserInfo, error) { + // first get user name and nick name from user profile + req, err := p.buildAPIRequest(githubUserProfileApiUrl) + + if err != nil { + log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user info request, because %s", err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + oauth2Client := oauth2.NewClient(c, oauth2.StaticTokenSource(oauth2Token)) + resp, err := oauth2Client.Do(req) + + if err != nil { + log.Errorf(c, "[github_oauth2_datasource_test.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, "[github_oauth2_datasource_test.GetUserInfo] user profile response is %s", body) + + if resp.StatusCode != 200 { + log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode) + return nil, errs.ErrFailedToRequestRemoteApi + } + + userProfileResp, err := p.parseUserProfile(c, body) + + if err != nil { + return nil, err + } + + // then get user primary email + req, err = p.buildAPIRequest(githubUserEmailApiUrl) + + if err != nil { + log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user emails request, because %s", err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + resp, err = oauth2Client.Do(req) + + if err != nil { + log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user emails response, because %s", err.Error()) + return nil, errs.ErrFailedToRequestRemoteApi + } + + defer resp.Body.Close() + body, err = io.ReadAll(resp.Body) + + log.Debugf(c, "[github_oauth2_datasource_test.GetUserInfo] user emails response is %s", body) + + if resp.StatusCode != 200 { + log.Errorf(c, "[github_oauth2_datasource_test.GetUserInfo] failed to get user emails response, because response code is %d", resp.StatusCode) + return nil, errs.ErrFailedToRequestRemoteApi + } + + email, err := p.parsePrimaryEmail(c, body) + + if err != nil { + return nil, err + } + + return &data.OAuth2UserInfo{ + UserName: userProfileResp.Login, + Email: email, + NickName: userProfileResp.Name, + }, nil +} + +func (p *GithubOAuth2Provider) parseUserProfile(c core.Context, body []byte) (*githubUserProfileResponse, error) { + userProfileResp := &githubUserProfileResponse{} + err := json.Unmarshal(body, &userProfileResp) + + if err != nil { + log.Warnf(c, "[github_oauth2_datasource.parseUserProfile] failed to parse user profile response body, because %s", err.Error()) + return nil, errs.ErrCannotRetrieveUserInfo + } + + if userProfileResp.Login == "" { + log.Warnf(c, "[github_oauth2_datasource.parseUserProfile] invalid user profile response body") + return nil, errs.ErrCannotRetrieveUserInfo + } + + return userProfileResp, nil +} + +func (p *GithubOAuth2Provider) parsePrimaryEmail(c core.Context, body []byte) (string, error) { + emailsResp := make([]githubUserEmailsResponse, 0) + err := json.Unmarshal(body, &emailsResp) + + if err != nil { + log.Warnf(c, "[github_oauth2_datasource.parsePrimaryEmail] failed to parse user emails response body, because %s", err.Error()) + return "", errs.ErrCannotRetrieveUserInfo + } + + for _, emailEntry := range emailsResp { + if emailEntry.Primary && emailEntry.Verified { + return emailEntry.Email, nil + } + } + + return "", nil +} + +func (p *GithubOAuth2Provider) buildAPIRequest(url string) (*http.Request, error) { + req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err @@ -49,34 +168,20 @@ func (s *GithubOAuth2DataSource) GetUserInfoRequest() (*http.Request, error) { return req, nil } -// GetScopes returns the scopes required by the Github provider -func (p *GithubOAuth2DataSource) GetScopes() []string { - return []string{"read:user"} -} - -// ParseUserInfo returns the user info by parsing the response body -func (p *GithubOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) { - userInfoResp := &githubUserProfileResponse{} - err := json.Unmarshal(body, &userInfoResp) - - if err != nil { - log.Warnf(c, "[github_oauth2_datasource.ParseUserInfo] failed to parse user profile response body, because %s", err.Error()) - return nil, errs.ErrCannotRetrieveUserInfo - } - - if userInfoResp.Login == "" { - log.Warnf(c, "[github_oauth2_datasource.ParseUserInfo] invalid user profile response body") - return nil, errs.ErrCannotRetrieveUserInfo - } - - return &data.OAuth2UserInfo{ - UserName: userInfoResp.Login, - Email: userInfoResp.Email, - NickName: userInfoResp.Name, - }, nil -} - // NewGithubOAuth2Provider creates a new Github OAuth 2.0 provider instance func NewGithubOAuth2Provider(config *settings.Config, redirectUrl string) (provider.OAuth2Provider, error) { - return common.NewCommonOAuth2Provider(config, redirectUrl, &GithubOAuth2DataSource{}), nil + oauth2Config := &oauth2.Config{ + ClientID: config.OAuth2ClientID, + ClientSecret: config.OAuth2ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: githubOAuth2AuthUrl, + TokenURL: githubOAuth2TokenUrl, + }, + RedirectURL: redirectUrl, + Scopes: githubOAuth2Scopes, + } + + return &GithubOAuth2Provider{ + oauth2Config: oauth2Config, + }, nil } diff --git a/pkg/auth/oauth2/provider/github/github_oauth2_datasource_test.go b/pkg/auth/oauth2/provider/github/github_oauth2_datasource_test.go index 6b962b79..acb92788 100644 --- a/pkg/auth/oauth2/provider/github/github_oauth2_datasource_test.go +++ b/pkg/auth/oauth2/provider/github/github_oauth2_datasource_test.go @@ -9,18 +9,8 @@ import ( "github.com/mayswind/ezbookkeeping/pkg/errs" ) -func TestGithubOAuth2Datasource_GetUserInfoRequest(t *testing.T) { - datasource := &GithubOAuth2DataSource{} - req, err := datasource.GetUserInfoRequest() - - assert.Nil(t, err) - assert.Equal(t, "GET", req.Method) - assert.Equal(t, "https://api.github.com/user", req.URL.String()) - assert.Equal(t, "application/vnd.github+json", req.Header.Get("Accept")) -} - -func TestGithubOAuth2Datasource_ParseUserInfo_Success(t *testing.T) { - datasource := &GithubOAuth2DataSource{} +func TestGithubOAuth2Datasource_ParseUserProfile_Success(t *testing.T) { + datasource := &GithubOAuth2Provider{} responseContent := `{ "login": "octocat", "id": 1, @@ -67,25 +57,39 @@ func TestGithubOAuth2Datasource_ParseUserInfo_Success(t *testing.T) { "collaborators": 0 } }` - info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + info, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent)) assert.Nil(t, err) - assert.Equal(t, "octocat", info.UserName) - assert.Equal(t, "octocat@github.com", info.Email) - assert.Equal(t, "monalisa octocat", info.NickName) + assert.Equal(t, "octocat", info.Login) + assert.Equal(t, "monalisa octocat", info.Name) } -func TestGithubOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) { - datasource := &GithubOAuth2DataSource{} - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid")) - - assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) -} - -func TestGithubOAuth2Datasource_ParseUserInfo_EmptyLogin(t *testing.T) { - datasource := &GithubOAuth2DataSource{} +func TestGithubOAuth2Datasource_ParseUserProfile_EmptyLogin(t *testing.T) { + datasource := &GithubOAuth2Provider{} responseContent := `{"login": ""}` - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + _, err := datasource.parseUserProfile(core.NewNullContext(), []byte(responseContent)) assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) } + +func TestGithubOAuth2Datasource_ParsePrimaryEmail(t *testing.T) { + datasource := &GithubOAuth2Provider{} + responseContent := `[ + { + "email": "foo@bar.com", + "primary": false, + "verified": true, + "visibility": null + }, + { + "email": "octocat@github.com", + "primary": true, + "verified": true, + "visibility": "public" + } + ]` + email, err := datasource.parsePrimaryEmail(core.NewNullContext(), []byte(responseContent)) + + assert.Nil(t, err) + assert.Equal(t, "octocat@github.com", email) +} diff --git a/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource.go b/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource.go index 6924835b..a20a9cb4 100644 --- a/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource.go +++ b/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource.go @@ -65,7 +65,7 @@ func (s *NextcloudOAuth2DataSource) GetScopes() []string { } // ParseUserInfo returns the user info by parsing the response body -func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte) (*data.OAuth2UserInfo, error) { +func (s *NextcloudOAuth2DataSource) ParseUserInfo(c core.Context, body []byte, oauth2Client *http.Client) (*data.OAuth2UserInfo, error) { userInfoResp := &nextcloudUserInfoResponse{} err := json.Unmarshal(body, &userInfoResp) diff --git a/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource_test.go b/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource_test.go index 17ae6485..869ae817 100644 --- a/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource_test.go +++ b/pkg/auth/oauth2/provider/nextcloud/nextcloud_oauth2_datasource_test.go @@ -1,6 +1,7 @@ package nextcloud import ( + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -56,7 +57,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) { } } }` - info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + info, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{}) assert.Nil(t, err) assert.Equal(t, "user1", info.UserName) @@ -66,7 +67,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_Success(t *testing.T) { func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) { datasource := &NextcloudOAuth2DataSource{} - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid")) + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte("invalid"), &http.Client{}) assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) } @@ -74,7 +75,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_InvalidJson(t *testing.T) { func TestNextcloudOAuth2Datasource_ParseUserInfo_MissingFields(t *testing.T) { datasource := &NextcloudOAuth2DataSource{} responseContent := `{"ocs": {}}` - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{}) assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) } @@ -90,7 +91,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_Non200StatusCode(t *testing.T) "data": {} } }` - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{}) assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) } @@ -110,7 +111,7 @@ func TestNextcloudOAuth2Datasource_ParseUserInfo_EmptyID(t *testing.T) { } } }` - _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent), &http.Client{}) assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) }