diff --git a/providers/gitee/gitee.go b/providers/gitee/gitee.go new file mode 100644 index 000000000..83376d07b --- /dev/null +++ b/providers/gitee/gitee.go @@ -0,0 +1,230 @@ +// Package gitee implements the OAuth2 protocol for authenticating users through Gitee. +// This package can be used as a reference implementation of an OAuth2 provider for Goth. +package gitee + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "github.com/markbates/goth" + "golang.org/x/oauth2" +) + +const providerName = "gitee" + +var ( + AuthURL = "https://gitee.com/oauth/authorize" + TokenURL = "https://gitee.com/oauth/token" + ProfileURL = "https://gitee.com/api/v5/user" + EmailURL = "https://gitee.com/api/v5/emails" +) + +var ErrNoPrimaryEmail = errors.New("The user does not have a primary email on Gitee") + +type Provider struct { + Key string + Secret string + CallbackURL string + HTTPClient *http.Client + config *oauth2.Config + providerName string + profileURL string + emailURL string +} + +// Name is the name used to retrieve this provider later. +func (p *Provider) Name() string { + return p.providerName +} + +// SetName is to update the name of the provider (needed in case of multiple providers of 1 type) +func (p *Provider) SetName(name string) { + p.providerName = name +} + +func (p *Provider) Client() *http.Client { + return goth.HTTPClientWithFallBack(p.HTTPClient) +} + +// Debug is a no-op for the package. +func (p *Provider) Debug(debug bool) { + // todo:for debug log? +} + +// BeginAuth asks Gitee for an authentication endpoint. +func (p *Provider) BeginAuth(state string) (goth.Session, error) { + authURL := p.config.AuthCodeURL(state) + session := &Session{ + AuthURL: authURL, + } + return session, nil +} + +func (p *Provider) FetchUser(session goth.Session) (goth.User, error) { + user := goth.User{ + Provider: p.Name(), + } + + sess, ok := session.(*Session) + if !ok { + return user, errors.New("invalid session assert") + } + if sess.AccessToken == "" { + // data is not yet retrieved since accessToken is still empty + return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName) + } + user.AccessToken = sess.AccessToken + + req, err := http.NewRequest(http.MethodGet, p.profileURL, nil) + if err != nil { + return user, nil + } + + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + rsp, err := p.Client().Do(req) + if err != nil { + return user, nil + } + defer rsp.Body.Close() + + if rsp.StatusCode != http.StatusOK { + return user, fmt.Errorf("gitee API responded with a %d trying to fetch user information", rsp.StatusCode) + } + + err = parseUserFromBody(rsp.Body, &user) + if err != nil { + return user, err + } + + if user.Email == "" { + for _, scope := range p.config.Scopes { + if strings.TrimSpace(scope) == "user" || strings.TrimSpace(scope) == "emails" { + user.Email, err = getPrivateMail(p, sess) + if err != nil { + return user, err + } + break + } + } + } + + return user, nil +} + +func parseUserFromBody(r io.Reader, user *goth.User) error { + err := json.NewDecoder(r).Decode(&user.RawData) + if err != nil { + fmt.Printf("x2:%+v", err) + return err + } + + if login, ok := user.RawData["login"].(string); ok { + user.Name = login + } + if name, ok := user.RawData["name"].(string); ok { + user.NickName = name + } + if email, ok := user.RawData["email"].(string); ok { + user.Email = email + } + if bio, ok := user.RawData["bio"].(string); ok { + user.Description = bio + } + if avatarURL, ok := user.RawData["avatar_url"].(string); ok { + user.AvatarURL = avatarURL + } + + return nil +} + +type emails []struct { + Email string `json:"email"` + State string `json:"state"` + Scope []string `json:"scope"` +} + +func getPrivateMail(p *Provider, sess *Session) (email string, err error) { + req, err := http.NewRequest(http.MethodGet, p.emailURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) + + rsp, err := p.Client().Do(req) + if err != nil { + return "", err + } + defer rsp.Body.Close() + + if rsp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gitee API responded with a %d trying to get user email", rsp.StatusCode) + } + + var emails emails + err = json.NewDecoder(rsp.Body).Decode(&emails) + if err != nil { + return "", err + } + + for _, email := range emails { + for _, scope := range email.Scope { + if scope == "primary" { + return email.Email, nil + } + } + } + + return "", ErrNoPrimaryEmail +} + +// RefreshToken refresh token is provided by Gitee +func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) { + return nil, errors.New("todo: Refresh token is not provided by Gitee") +} + +func (p *Provider) RefreshTokenAvailable() bool { + return false +} + +func New(key, secret, callbackURL string, scopes ...string) *Provider { + return NewCustomisedURL(key, secret, callbackURL, AuthURL, TokenURL, ProfileURL, EmailURL, scopes...) +} + +func NewCustomisedURL(key, secret, callbackURL, authURL, tokenURL, profileURL, emailURL string, scopes ...string) *Provider { + p := &Provider{ + Key: key, + Secret: secret, + CallbackURL: callbackURL, + HTTPClient: &http.Client{}, + config: &oauth2.Config{}, + providerName: providerName, + profileURL: ProfileURL, + emailURL: EmailURL, + } + p.config = newConfig(p, authURL, tokenURL, scopes) + return p +} + +func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config { + c := &oauth2.Config{ + ClientID: provider.Key, + ClientSecret: provider.Secret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + RedirectURL: provider.CallbackURL, + Scopes: []string{}, + } + + for _, scope := range scopes { + c.Scopes = append(c.Scopes, scope) + } + + return c +} diff --git a/providers/gitee/gitee_test.go b/providers/gitee/gitee_test.go new file mode 100644 index 000000000..a42eff5ce --- /dev/null +++ b/providers/gitee/gitee_test.go @@ -0,0 +1,92 @@ +package gitee + +import ( + "fmt" + "os" + "strings" + "testing" + + "github.com/markbates/goth" + "github.com/stretchr/testify/assert" +) + +func newProvider() *Provider { + return New(os.Getenv("GITEE_KEY"), os.Getenv("GITEE_SECRET"), "/foo", "user") +} + +func newCustomisedProvider() *Provider { + return NewCustomisedURL(os.Getenv("GITEE_KEY"), os.Getenv("GITEE_SECRET"), "/foo", "http://authURL", "http://tokenURL", "http://profileURL", "http://emailURL") +} + +func Test_New(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := newProvider() + a.Equal(provider.Key, os.Getenv("GITEE_KEY")) + a.Equal(provider.Secret, os.Getenv("GITEE_SECRET")) + a.Equal(provider.CallbackURL, "/foo") +} + +func Test_NewCustomisedURL(t *testing.T) { + t.Parallel() + a := assert.New(t) + p := newCustomisedProvider() + sess, err := p.BeginAuth("state") + a.NoError(err) + + authURL, err := sess.GetAuthURL() + a.NoError(err) + a.Contains(authURL, "http://authURL") +} + +func TestImplementProvider(t *testing.T) { + t.Parallel() + a := assert.New(t) + a.Implements((*goth.Provider)(nil), newProvider()) +} + +func TestBeginAuth(t *testing.T) { + t.Parallel() + a := assert.New(t) + + provider := newProvider() + sess, err := provider.BeginAuth("state") + a.NoError(err) + + authURL, err := sess.GetAuthURL() + a.NoError(err) + a.Contains(authURL, "gitee.com/oauth/authorize") + a.Contains(authURL, fmt.Sprintf("client_id=%s", os.Getenv("GITEE_KEY"))) + a.Contains(authURL, "state=state") + a.Contains(authURL, "scope=user") +} + +func TestSessionFromJSON(t *testing.T) { + t.Parallel() + a := assert.New(t) + provider := newProvider() + sess, err := provider.UnmarshalSession(`{"AuthURL":"http://gitee.com/auth_url","AccessToken":"01234567890"}`) + a.NoError(err) + authURL, err := sess.GetAuthURL() + a.NoError(err) + + a.Equal(authURL, "http://gitee.com/auth_url") +} + +func Test_parse(t *testing.T) { + a := assert.New(t) + s := ` + { + "id": 123456, + "login": "login_name", + "name": "name", + "bio": "some bio", + "email": "" + } + ` + r := strings.NewReader(s) + user := &goth.User{} + err := parseUserFromBody(r, user) + a.NoError(err) +} diff --git a/providers/gitee/session.go b/providers/gitee/session.go new file mode 100644 index 000000000..9c46e5f25 --- /dev/null +++ b/providers/gitee/session.go @@ -0,0 +1,52 @@ +package gitee + +import ( + "encoding/json" + "errors" + "strings" + + "github.com/markbates/goth" +) + +type Session struct { + AuthURL string + AccessToken string +} + +func (s *Session) GetAuthURL() (string, error) { + if s.AuthURL == "" { + return "", errors.New(goth.NoAuthUrlErrorMessage) + } + return s.AuthURL, nil +} + +func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) { + p := provider.(*Provider) + token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code")) + if err != nil { + return "", err + } + + if !token.Valid() { + return "", errors.New("Invalid token received from provider") + } + + s.AccessToken = token.AccessToken + return token.AccessToken, err +} + +// Marshal the session into a string +func (s *Session) Marshal() string { + b, _ := json.Marshal(s) + return string(b) +} + +func (s *Session) String() string { + return s.Marshal() +} + +func (p *Provider) UnmarshalSession(data string) (goth.Session, error) { + sess := &Session{} + err := json.NewDecoder(strings.NewReader(data)).Decode(sess) + return sess, err +} diff --git a/providers/gitee/session_test.go b/providers/gitee/session_test.go new file mode 100644 index 000000000..567336453 --- /dev/null +++ b/providers/gitee/session_test.go @@ -0,0 +1 @@ +package gitee