diff --git a/conf/ezbookkeeping.ini b/conf/ezbookkeeping.ini index b33be93f..91db2136 100644 --- a/conf/ezbookkeeping.ini +++ b/conf/ezbookkeeping.ini @@ -297,7 +297,7 @@ oauth2_user_identifier = email # For "oauth2" authentication only, if the user returned by OAuth 2.0 is not registered, automatically create a new user (requires "enable_register" to be set to true) oauth2_auto_register = true -# For "oauth2" authentication only, OAuth 2.0 provider, supports "nextcloud" currently +# For "oauth2" authentication only, OAuth 2.0 provider, supports "nextcloud" and "github" currently oauth2_provider = # For "oauth2" authentication only, OAuth 2.0 state expired seconds (60 - 4294967295), default is 300 (5 minutes) diff --git a/pkg/auth/oauth2/github_oauth2_datasource.go b/pkg/auth/oauth2/github_oauth2_datasource.go new file mode 100644 index 00000000..dce88418 --- /dev/null +++ b/pkg/auth/oauth2/github_oauth2_datasource.go @@ -0,0 +1,80 @@ +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 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 { + CommonOAuth2DataSource +} + +// 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" +} + +// 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" +} + +// 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) + + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/vnd.github+json") + 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) (*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 &OAuth2UserInfo{ + UserName: userInfoResp.Login, + Email: userInfoResp.Email, + NickName: userInfoResp.Name, + }, nil +} + +// NewGithubOAuth2Provider creates a new Github OAuth 2.0 provider instance +func NewGithubOAuth2Provider() OAuth2Provider { + return &CommonOAuth2Provider{ + dataSource: &GithubOAuth2DataSource{}, + } +} diff --git a/pkg/auth/oauth2/github_oauth2_datasource_test.go b/pkg/auth/oauth2/github_oauth2_datasource_test.go new file mode 100644 index 00000000..7192ad66 --- /dev/null +++ b/pkg/auth/oauth2/github_oauth2_datasource_test.go @@ -0,0 +1,91 @@ +package oauth2 + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mayswind/ezbookkeeping/pkg/core" + "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{} + responseContent := `{ + "login": "octocat", + "id": 1, + "node_id": "MDQ6VXNlcjE=", + "avatar_url": "https://github.com/images/error/octocat_happy.gif", + "gravatar_id": "", + "url": "https://api.github.com/users/octocat", + "html_url": "https://github.com/octocat", + "followers_url": "https://api.github.com/users/octocat/followers", + "following_url": "https://api.github.com/users/octocat/following{/other_user}", + "gists_url": "https://api.github.com/users/octocat/gists{/gist_id}", + "starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/octocat/subscriptions", + "organizations_url": "https://api.github.com/users/octocat/orgs", + "repos_url": "https://api.github.com/users/octocat/repos", + "events_url": "https://api.github.com/users/octocat/events{/privacy}", + "received_events_url": "https://api.github.com/users/octocat/received_events", + "type": "User", + "site_admin": false, + "name": "monalisa octocat", + "company": "GitHub", + "blog": "https://github.com/blog", + "location": "San Francisco", + "email": "octocat@github.com", + "hireable": false, + "bio": "There once was...", + "twitter_username": "monatheoctocat", + "public_repos": 2, + "public_gists": 1, + "followers": 20, + "following": 0, + "created_at": "2008-01-14T04:33:35Z", + "updated_at": "2008-01-14T04:33:35Z", + "private_gists": 81, + "total_private_repos": 100, + "owned_private_repos": 100, + "disk_usage": 10000, + "collaborators": 8, + "two_factor_authentication": true, + "plan": { + "name": "Medium", + "space": 400, + "private_repos": 20, + "collaborators": 0 + } + }` + info, err := datasource.ParseUserInfo(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) +} + +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{} + responseContent := `{"login": ""}` + _, err := datasource.ParseUserInfo(core.NewNullContext(), []byte(responseContent)) + + assert.Equal(t, errs.ErrCannotRetrieveUserInfo, err) +} diff --git a/pkg/auth/oauth2/oauth2_authentication.go b/pkg/auth/oauth2/oauth2_authentication.go index 79f25c2b..b68f1c8c 100644 --- a/pkg/auth/oauth2/oauth2_authentication.go +++ b/pkg/auth/oauth2/oauth2_authentication.go @@ -40,6 +40,9 @@ func InitializeOAuth2Provider(config *settings.Config) error { if config.OAuth2Provider == settings.OAuth2ProviderNextcloud { oauth2Provider = NewNextcloudOAuth2Provider(config.OAuth2NextcloudBaseUrl) externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD + } else if config.OAuth2Provider == settings.OAuth2ProviderGithub { + oauth2Provider = NewGithubOAuth2Provider() + externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB } else { return errs.ErrInvalidOAuth2Provider } diff --git a/pkg/core/user_external_auth_type.go b/pkg/core/user_external_auth_type.go index 858dd287..35ce0200 100644 --- a/pkg/core/user_external_auth_type.go +++ b/pkg/core/user_external_auth_type.go @@ -6,12 +6,14 @@ type UserExternalAuthType string // User External Auth Type const ( USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD UserExternalAuthType = "nextcloud" + USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB UserExternalAuthType = "github" ) // IsValid checks if the UserExternalAuthType is valid func (t UserExternalAuthType) IsValid() bool { switch t { - case USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD: + case USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD, + USER_EXTERNAL_AUTH_TYPE_OAUTH2_GITHUB: return true } return false diff --git a/pkg/settings/setting.go b/pkg/settings/setting.go index 5238e0f1..cdfcd0a7 100644 --- a/pkg/settings/setting.go +++ b/pkg/settings/setting.go @@ -94,6 +94,7 @@ const ( // OAuth 2.0 provider types const ( OAuth2ProviderNextcloud string = "nextcloud" + OAuth2ProviderGithub string = "github" ) // Map provider types @@ -1002,6 +1003,8 @@ func loadAuthConfiguration(config *Config, configFile *ini.File, sectionName str config.OAuth2Provider = "" } else if oauth2Provider == OAuth2ProviderNextcloud { config.OAuth2Provider = OAuth2ProviderNextcloud + } else if oauth2Provider == OAuth2ProviderGithub { + config.OAuth2Provider = OAuth2ProviderGithub } else { return errs.ErrInvalidOAuth2Provider } diff --git a/src/consts/oauth2.ts b/src/consts/oauth2.ts index e6e9b228..87f5454d 100644 --- a/src/consts/oauth2.ts +++ b/src/consts/oauth2.ts @@ -1,3 +1,4 @@ export const OAUTH2_PROVIDER_DISPLAY_NAME: Record = { - 'nextcloud': 'Nextcloud' -} + 'nextcloud': 'Nextcloud', + 'github': 'GitHub', +};