diff --git a/.gitignore b/.gitignore index 2c09a372..af2459ca 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ *.out .vscode *.cov -Dockerfile \ No newline at end of file +Dockerfile +.idea diff --git a/README.md b/README.md index 9e48ae4c..86b7854b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # auth - authentication via oauth2, direct and email [![Build Status](https://github.com/go-pkgz/auth/workflows/build/badge.svg)](https://github.com/go-pkgz/auth/actions) [![Coverage Status](https://coveralls.io/repos/github/go-pkgz/auth/badge.svg?branch=master)](https://coveralls.io/github/go-pkgz/auth?branch=master) [![godoc](https://godoc.org/github.com/go-pkgz/auth?status.svg)](https://pkg.go.dev/github.com/go-pkgz/auth?tab=doc) -This library provides "social login" with Github, Google, Facebook, Microsoft, Twitter, Yandex, Battle.net, Apple, Patreon, Discord and Telegram as well as custom auth providers and email verification. +This library provides "social login" with Github, Google, Facebook, Microsoft, Twitter, Yandex, Battle.net, Apple, Patreon, Discord, Telegram and Twitch as well as custom auth providers and email verification. - Multiple oauth2 providers can be used at the same time - Special `dev` provider allows local testing and development @@ -617,6 +617,12 @@ For more details refer to [Complete Guide of Battle.net OAuth API and Login Butt 1. In the field **Callback URLs** enter the correct url of your callback handler e.g. https://example.mysite.com/{route}/twitter/callback 1. Under **Key and tokens** take note of the **Consumer API Key** and **Consumer API Secret key**. Those will be used as `cid` and `csecret` +#### Twitch Auth Provider #### +1. Create a new Twitch application https://dev.twitch.tv/console/apps/create +2. Fill **"Name"**, **"OAuth Redirect URL"** and choose category +3. Take note of the **Client ID** and **Client Secret** +4. Detailed instructions, step by step https://dev.twitch.tv/docs/authentication/register-app/ + ## XSRF Protections By default, the XSRF protections will apply to all requests which reach the `middlewares.Auth`, `middlewares.Admin` or `middlewares.RBAC` middlewares. This will require setting a request header diff --git a/v2/auth.go b/v2/auth.go index 4245ac4c..9bb6d5a1 100644 --- a/v2/auth.go +++ b/v2/auth.go @@ -260,6 +260,8 @@ func (s *Service) addProviderByName(name string, p provider.Params) { prov = provider.NewPatreon(p) case "discord": prov = provider.NewDiscord(p) + case "twitch": + prov = provider.NewTwitch(p) case "dev": prov = provider.NewDev(p) default: diff --git a/v2/auth_test.go b/v2/auth_test.go index 3e5ddbbe..5730b675 100644 --- a/v2/auth_test.go +++ b/v2/auth_test.go @@ -62,6 +62,7 @@ func TestProvider(t *testing.T) { svc.AddProvider("battlenet", "cid", "csecret") svc.AddProvider("patreon", "cid", "csecret") svc.AddProvider("discord", "cid", "csecret") + svc.AddProvider("twitch", "cid", "csecret") svc.AddProvider("bad", "cid", "csecret") c := customHandler{} @@ -82,7 +83,7 @@ func TestProvider(t *testing.T) { assert.Equal(t, "github", op.Name()) pp := svc.Providers() - assert.Equal(t, 11, len(pp)) + assert.Equal(t, 12, len(pp)) ch, err := svc.Provider("telegramBotMySiteCom") assert.NoError(t, err) diff --git a/v2/provider/apple.go b/v2/provider/apple.go index 4a2fbcd0..27e8d772 100644 --- a/v2/provider/apple.go +++ b/v2/provider/apple.go @@ -49,17 +49,7 @@ const ( // appleVerificationResponse is based on https://developer.apple.com/documentation/signinwithapplerestapi/tokenresponse type appleVerificationResponse struct { - // A token used to access allowed user data, but now not implemented public interface for it. - AccessToken string `json:"access_token"` - - // Access token type, always equal the "bearer". - TokenType string `json:"token_type"` - - // Access token expires time in seconds. Always equal 3600 seconds (1 hour) - ExpiresIn int `json:"expires_in"` - - // The refresh token used to regenerate new access tokens. - RefreshToken string `json:"refresh_token"` + AccessTokenResponse // Main JSON Web Token that contains the user’s identity information. IDToken string `json:"id_token"` @@ -323,7 +313,7 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { } var resp appleVerificationResponse - err = ah.exchange(context.Background(), code, ah.makeRedirURL(r.URL.Path), &resp) + err = ah.exchange(context.Background(), code, makeRedirectURL(ah.URL, r.URL.Path), &resp) if err != nil { rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "exchange failed") return @@ -525,16 +515,9 @@ func (ah *AppleHandler) prepareLoginURL(state, path string) (string, error) { query.Set("response_mode", ah.conf.ResponseMode) query.Set("client_id", ah.conf.ClientID) query.Set("scope", scopesList) - query.Set("redirect_uri", ah.makeRedirURL(path)) + query.Set("redirect_uri", makeRedirectURL(ah.URL, path)) authURL.RawQuery = query.Encode() return authURL.String(), nil } - -func (ah AppleHandler) makeRedirURL(path string) string { - elems := strings.Split(path, "/") - newPath := strings.Join(elems[:len(elems)-1], "/") - - return strings.TrimRight(ah.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix -} diff --git a/v2/provider/apple_test.go b/v2/provider/apple_test.go index 4584777d..8c10b83e 100644 --- a/v2/provider/apple_test.go +++ b/v2/provider/apple_test.go @@ -259,7 +259,7 @@ func TestAppleHandlerMakeRedirURL(t *testing.T) { for i := range cases { c := cases[i] ah.URL = c.rootURL - assert.Equal(t, c.out, ah.makeRedirURL(c.route)) + assert.Equal(t, c.out, makeRedirectURL(ah.URL, c.route)) } } @@ -356,11 +356,13 @@ func TestAppleHandler_Exchange(t *testing.T) { err = ah.exchange(context.Background(), "1122334455", "url/callback", &testAppleResponse) assert.NoError(t, err) assert.Equal(t, &appleVerificationResponse{ - AccessToken: "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", - ExpiresIn: 3600, - TokenType: "bearer", - RefreshToken: "IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", - IDToken: testResponseToken, + AccessTokenResponse: AccessTokenResponse{ + AccessToken: "MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3", + ExpiresIn: 3600, + TokenType: "bearer", + RefreshToken: "IwOGYzYTlmM2YxOTQ5MGE3YmNmMDFkNTVk", + }, + IDToken: testResponseToken, }, &testAppleResponse) testAppleResponse = appleVerificationResponse{} // clear response for next checking diff --git a/v2/provider/helpers.go b/v2/provider/helpers.go new file mode 100644 index 00000000..3c469831 --- /dev/null +++ b/v2/provider/helpers.go @@ -0,0 +1,18 @@ +package provider + +import "strings" + +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + Scope []string `json:"scope,omitempty"` +} + +func makeRedirectURL(url, path string) string { + elems := strings.Split(path, "/") + newPath := strings.Join(elems[:len(elems)-1], "/") + + return strings.TrimRight(url, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix +} diff --git a/v2/provider/helpers_test.go b/v2/provider/helpers_test.go new file mode 100644 index 00000000..622691ac --- /dev/null +++ b/v2/provider/helpers_test.go @@ -0,0 +1,27 @@ +package provider + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_makeRedirectURL(t *testing.T) { + type args struct { + url string + path string + } + + tests := []struct { + name string + args args + want string + }{ + {name: "", args: args{url: "https://some-site.com", path: "/"}, want: "https://some-site.com/callback"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, makeRedirectURL(tt.args.url, tt.args.path), "makeRedirectURL(%v, %v)", tt.args.url, tt.args.path) + }) + } +} diff --git a/v2/provider/twitch.go b/v2/provider/twitch.go new file mode 100644 index 00000000..f40897b5 --- /dev/null +++ b/v2/provider/twitch.go @@ -0,0 +1,289 @@ +package provider + +import ( + "context" + "crypto/sha1" + "encoding/json" + "fmt" + "github.com/go-pkgz/auth/v2/token" + "github.com/go-pkgz/rest" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" + "golang.org/x/oauth2/twitch" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type twitchUser struct { + Id string `json:"id"` + Login string `json:"login,omitempty"` + Name string `json:"display_name,omitempty"` + Email string `json:"email,omitempty"` + ProfileImageURL string `json:"profile_image_url,omitempty"` +} + +type twitchRaw struct { + Data []twitchUser `json:"data"` +} + +// TwitchHandler implements login via Twitch +type TwitchHandler struct { + Params + endpoint oauth2.Endpoint + infoURL string + scopes []string + mapUser func(data []byte) token.User +} + +func (h TwitchHandler) Name() string { + return "twitch" +} + +func (h TwitchHandler) LoginHandler(w http.ResponseWriter, r *http.Request) { + h.Logf("[DEBUG] login with %s", h.Name()) + + state, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to make oauth2 state") + return + } + + cid, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to make claim's id") + return + } + + claims := token.Claims{ + Handshake: &token.Handshake{ + State: state, + }, + RegisteredClaims: jwt.RegisteredClaims{ + ID: cid, + Audience: jwt.ClaimStrings{r.URL.Query().Get("site")}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + NotBefore: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)), + }, + } + + if _, err = h.JwtService.Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to set token") + return + } + + loginURL, err := h.redirectURL(r, state) + if err != nil { + errMsg := fmt.Sprintf("prepare login url for [%s] provider failed", h.Name()) + h.Logf("[ERROR] %s", errMsg) + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, errMsg) + return + } + h.Logf("[DEBUG] login url %s, claims=%+v", loginURL, claims) + + http.Redirect(w, r, loginURL, http.StatusFound) +} + +func (h TwitchHandler) AuthHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "read callback response from data failed") + return + } + + state := r.FormValue("state") + code := r.FormValue("code") + + oauthClaims, _, err := h.JwtService.Get(r) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to get token") + return + } + + if oauthClaims.Handshake == nil { + rest.SendErrorJSON(w, r, h.L, http.StatusForbidden, nil, "invalid handshake token") + return + } + + if oauthClaims.Handshake.State != state { + rest.SendErrorJSON(w, r, h.L, http.StatusForbidden, nil, "unexpected state") + return + } + + accessToken, err := h.requestAccessToken(r, code) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to obtain access token") + return + } + + h.Logf("[DEBUG] response data %+v", accessToken) + + userInfo, err := h.requestUserInfo(accessToken) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "user not found") + return + } + + u := h.mapUser(userInfo) + + u, err = setAvatar(h.AvatarSaver, u, &http.Client{Timeout: 5 * time.Second}) + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to save avatar to proxy") + return + } + + cid, err := randToken() + if err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to make claim's id") + return + } + + claims := token.Claims{ + User: &u, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: h.Issuer, + ID: cid, + Audience: oauthClaims.Audience, + }, + SessionOnly: false, + } + + if _, err := h.JwtService.Set(w, claims); err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to set token") + return + } + + rest.RenderJSON(w, claims.User) +} + +func (h TwitchHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { + if _, _, err := h.JwtService.Get(r); err != nil { + rest.SendErrorJSON(w, r, h.L, http.StatusForbidden, err, "logout not allowed") + return + } + h.JwtService.Reset(w) +} + +// redirectURL builds a redirect URL to the Twitch login page +func (h TwitchHandler) redirectURL(r *http.Request, state string) (string, error) { + authURL, err := url.Parse(h.endpoint.AuthURL) + if err != nil { + return "", err + } + + query := authURL.Query() + query.Set("client_id", h.Cid) + query.Set("response_type", "code") + query.Set("scope", "user:read:email") + query.Set("redirect_uri", makeRedirectURL(h.URL, r.URL.Path)) + query.Set("force_verify", "false") + query.Set("state", state) + authURL.RawQuery = query.Encode() + + return authURL.String(), nil +} + +// requestUserInfo requests information about the user +// https://dev.twitch.tv/docs/api/reference/#get-users +func (h TwitchHandler) requestUserInfo(accessToken *AccessTokenResponse) ([]byte, error) { + client := http.Client{Timeout: time.Second * 10} + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, h.infoURL, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Client-ID", h.Cid) + req.Header.Add("Authorization", "Bearer "+accessToken.AccessToken) + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + defer func() { + if err = res.Body.Close(); err != nil { + h.L.Logf("[ERROR] close request body failed when get user info: %v", err) + } + }() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s user info error: %s", h.Name(), res.Status) + } + + body, err := io.ReadAll(res.Body) + + return body, err +} + +// requestAccessToken requests an access token +// https://dev.twitch.tv/docs/api/get-started/#get-an-oauth-token +func (h TwitchHandler) requestAccessToken(r *http.Request, code string) (*AccessTokenResponse, error) { + data := url.Values{} + data.Set("client_id", h.Cid) + data.Set("client_secret", h.Csecret) + data.Set("code", code) + data.Set("grant_type", "authorization_code") + data.Set("redirect_uri", makeRedirectURL(h.URL, r.URL.Path)) + + client := http.Client{Timeout: time.Second * 10} + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, h.endpoint.TokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + res, err := client.Do(req) + if err != nil { + return nil, err + } + + var result AccessTokenResponse + err = json.NewDecoder(res.Body).Decode(&result) + if err != nil { + return nil, fmt.Errorf("unmarshalling data from %s service response failed: %w", h.Name(), err) + } + + defer func() { + if err = res.Body.Close(); err != nil { + h.L.Logf("[ERROR] close request body failed when get access token: %v", err) + } + }() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s token service error: %s", h.Name(), res.Status) + } + + return &result, err +} + +// NewTwitch makes Twitch OAuth2 provider +func NewTwitch(p Params) TwitchHandler { + return TwitchHandler{ + Params: p, + endpoint: twitch.Endpoint, + infoURL: "https://api.twitch.tv/helix/users", //https://dev.twitch.tv/docs/api/reference/#get-users + scopes: []string{"user:read:email"}, + mapUser: func(data []byte) token.User { + userInfo := token.User{} + raw := twitchRaw{} + if err := json.Unmarshal(data, &raw); err == nil { + userInfo.ID = "twitch_" + token.HashID(sha1.New(), raw.Data[0].Id) + userInfo.Name = raw.Data[0].Name + userInfo.Picture = raw.Data[0].ProfileImageURL + + if userInfo.Name == "" { + userInfo.Name = raw.Data[0].Login + } + + if userInfo.Name == "" { + userInfo.Name = raw.Data[0].Email + } + + if userInfo.Name == "" { + userInfo.Name = "twitch_" + raw.Data[0].Id + } + } + return userInfo + }, + } +} diff --git a/v2/provider/twitch_test.go b/v2/provider/twitch_test.go new file mode 100644 index 00000000..71484654 --- /dev/null +++ b/v2/provider/twitch_test.go @@ -0,0 +1,104 @@ +package provider + +import ( + "github.com/go-pkgz/auth/v2/token" + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_NewTwitch(t *testing.T) { + r := NewTwitch(Params{URL: "http://demo.remark42.com", Cid: "cid", Csecret: "cs"}) + assert.Equal(t, "twitch", r.Name()) + + user := r.mapUser([]byte(`{ + "data": [ + { + "id": "141981764", + "login": "twitchdev", + "display_name": "TwitchDev", + "type": "", + "broadcaster_type": "partner", + "description": "Supporting third-party developers building Twitch integrations from chatbots to game integrations.", + "profile_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + "offline_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/3f13ab61-ec78-4fe6-8481-8682cb3b0ac2-channel_offline_image-1920x1080.png", + "view_count": 5980557, + "email": "not-real@email.com", + "created_at": "2016-12-14T20:32:28Z" + } + ] + }`)) + assert.Equal(t, token.User{ + Name: "TwitchDev", + ID: "twitch_f35163dedfaa9e88c74a285225c5a120bb7fe07e", + Picture: "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + }, user, "got %+v", user) + + user = r.mapUser([]byte(`{ + "data": [ + { + "id": "141981764", + "login": "twitchdev", + "display_name": "", + "type": "", + "broadcaster_type": "partner", + "description": "Supporting third-party developers building Twitch integrations from chatbots to game integrations.", + "profile_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + "offline_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/3f13ab61-ec78-4fe6-8481-8682cb3b0ac2-channel_offline_image-1920x1080.png", + "view_count": 5980557, + "email": "not-real@email.com", + "created_at": "2016-12-14T20:32:28Z" + } + ] + }`)) + assert.Equal(t, token.User{ + Name: "twitchdev", + ID: "twitch_f35163dedfaa9e88c74a285225c5a120bb7fe07e", + Picture: "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + }, user, "got %+v", user) + + user = r.mapUser([]byte(`{ + "data": [ + { + "id": "141981764", + "login": "", + "display_name": "", + "type": "", + "broadcaster_type": "partner", + "description": "Supporting third-party developers building Twitch integrations from chatbots to game integrations.", + "profile_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + "offline_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/3f13ab61-ec78-4fe6-8481-8682cb3b0ac2-channel_offline_image-1920x1080.png", + "view_count": 5980557, + "email": "not-real@email.com", + "created_at": "2016-12-14T20:32:28Z" + } + ] + }`)) + assert.Equal(t, token.User{ + Name: "not-real@email.com", + ID: "twitch_f35163dedfaa9e88c74a285225c5a120bb7fe07e", + Picture: "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + }, user, "got %+v", user) + + user = r.mapUser([]byte(`{ + "data": [ + { + "id": "141981764", + "login": "", + "display_name": "", + "type": "", + "broadcaster_type": "partner", + "description": "Supporting third-party developers building Twitch integrations from chatbots to game integrations.", + "profile_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + "offline_image_url": "https://static-cdn.jtvnw.net/jtv_user_pictures/3f13ab61-ec78-4fe6-8481-8682cb3b0ac2-channel_offline_image-1920x1080.png", + "view_count": 5980557, + "email": "", + "created_at": "2016-12-14T20:32:28Z" + } + ] + }`)) + assert.Equal(t, token.User{ + Name: "twitch_141981764", + ID: "twitch_f35163dedfaa9e88c74a285225c5a120bb7fe07e", + Picture: "https://static-cdn.jtvnw.net/jtv_user_pictures/8a6381c7-d0c0-4576-b179-38bd5ce1d6af-profile_image-300x300.png", + }, user, "got %+v", user) +}