Discord Oauth2 support (#4476)

* add discord auth

* add vendor for discord

* fix syntax error

* make fmt

* update version of goth in use

* update markbates/goth
This commit is contained in:
techknowlogick 2019-01-13 14:06:22 -05:00 committed by GitHub
parent beab2df122
commit 5c44f751a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 308 additions and 26 deletions

8
Gopkg.lock generated
View File

@ -588,12 +588,13 @@
revision = "e3534c89ef969912856dfa39e56b09e58c5f5daf" revision = "e3534c89ef969912856dfa39e56b09e58c5f5daf"
[[projects]] [[projects]]
digest = "1:4b992ec853d0ea9bac3dcf09a64af61de1a392e6cb0eef2204c0c92f4ae6b911" digest = "1:aa7dcd6a0db70d514821f8739d0a22e7df33b499d8d399cf15b2858d44f8319e"
name = "github.com/markbates/goth" name = "github.com/markbates/goth"
packages = [ packages = [
".", ".",
"gothic", "gothic",
"providers/bitbucket", "providers/bitbucket",
"providers/discord",
"providers/dropbox", "providers/dropbox",
"providers/facebook", "providers/facebook",
"providers/github", "providers/github",
@ -603,8 +604,8 @@
"providers/twitter", "providers/twitter",
] ]
pruneopts = "NUT" pruneopts = "NUT"
revision = "bc6d8ddf751a745f37ca5567dbbfc4157bbf5da9" revision = "157987f620ff2fc5e1f6a1427a3685219fbf6ff4"
version = "v1.47.2" version = "v1.49.0"
[[projects]] [[projects]]
digest = "1:c9724c929d27a14475a45b17a267dbc60671c0bc2c5c05ed21f011f7b5bc9fb5" digest = "1:c9724c929d27a14475a45b17a267dbc60671c0bc2c5c05ed21f011f7b5bc9fb5"
@ -1179,6 +1180,7 @@
"github.com/markbates/goth", "github.com/markbates/goth",
"github.com/markbates/goth/gothic", "github.com/markbates/goth/gothic",
"github.com/markbates/goth/providers/bitbucket", "github.com/markbates/goth/providers/bitbucket",
"github.com/markbates/goth/providers/discord",
"github.com/markbates/goth/providers/dropbox", "github.com/markbates/goth/providers/dropbox",
"github.com/markbates/goth/providers/facebook", "github.com/markbates/goth/providers/facebook",
"github.com/markbates/goth/providers/github", "github.com/markbates/goth/providers/github",

View File

@ -43,6 +43,7 @@ var OAuth2Providers = map[string]OAuth2Provider{
"gplus": {Name: "gplus", DisplayName: "Google+", Image: "/img/auth/google_plus.png"}, "gplus": {Name: "gplus", DisplayName: "Google+", Image: "/img/auth/google_plus.png"},
"openidConnect": {Name: "openidConnect", DisplayName: "OpenID Connect", Image: "/img/auth/openid_connect.png"}, "openidConnect": {Name: "openidConnect", DisplayName: "OpenID Connect", Image: "/img/auth/openid_connect.png"},
"twitter": {Name: "twitter", DisplayName: "Twitter", Image: "/img/auth/twitter.png"}, "twitter": {Name: "twitter", DisplayName: "Twitter", Image: "/img/auth/twitter.png"},
"discord": {Name: "discord", DisplayName: "Discord", Image: "/img/auth/discord.png"},
} }
// OAuth2DefaultCustomURLMappings contains the map of default URL's for OAuth2 providers that are allowed to have custom urls // OAuth2DefaultCustomURLMappings contains the map of default URL's for OAuth2 providers that are allowed to have custom urls

View File

@ -16,6 +16,7 @@ import (
"github.com/markbates/goth" "github.com/markbates/goth"
"github.com/markbates/goth/gothic" "github.com/markbates/goth/gothic"
"github.com/markbates/goth/providers/bitbucket" "github.com/markbates/goth/providers/bitbucket"
"github.com/markbates/goth/providers/discord"
"github.com/markbates/goth/providers/dropbox" "github.com/markbates/goth/providers/dropbox"
"github.com/markbates/goth/providers/facebook" "github.com/markbates/goth/providers/facebook"
"github.com/markbates/goth/providers/github" "github.com/markbates/goth/providers/github"
@ -172,6 +173,8 @@ func createProvider(providerName, providerType, clientID, clientSecret, openIDCo
} }
case "twitter": case "twitter":
provider = twitter.NewAuthenticate(clientID, clientSecret, callbackURL) provider = twitter.NewAuthenticate(clientID, clientSecret, callbackURL)
case "discord":
provider = discord.New(clientID, clientSecret, callbackURL, discord.ScopeIdentify, discord.ScopeEmail)
} }
// always set the name if provider is created so we can support multiple setups of 1 provider // always set the name if provider is created so we can support multiple setups of 1 provider

View File

@ -1523,6 +1523,7 @@ auths.tip.gitlab = Register a new application on https://gitlab.com/profile/appl
auths.tip.google_plus = Obtain OAuth2 client credentials from the Google API console at https://console.developers.google.com/ auths.tip.google_plus = Obtain OAuth2 client credentials from the Google API console at https://console.developers.google.com/
auths.tip.openid_connect = Use the OpenID Connect Discovery URL (<server>/.well-known/openid-configuration) to specify the endpoints auths.tip.openid_connect = Use the OpenID Connect Discovery URL (<server>/.well-known/openid-configuration) to specify the endpoints
auths.tip.twitter = Go to https://dev.twitter.com/apps, create an application and ensure that the “Allow this application to be used to Sign in with Twitter” option is enabled auths.tip.twitter = Go to https://dev.twitter.com/apps, create an application and ensure that the “Allow this application to be used to Sign in with Twitter” option is enabled
auths.tip.discord = Register a new application on https://discordapp.com/developers/applications/me
auths.edit = Edit Authentication Source auths.edit = Edit Authentication Source
auths.activated = This Authentication Source is Activated auths.activated = This Authentication Source is Activated
auths.new_success = The authentication '%s' has been added. auths.new_success = The authentication '%s' has been added.

BIN
public/img/auth/discord.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -108,6 +108,8 @@
<span>{{.i18n.Tr "admin.auths.tip.openid_connect"}}</span> <span>{{.i18n.Tr "admin.auths.tip.openid_connect"}}</span>
<li>Twitter</li> <li>Twitter</li>
<span>{{.i18n.Tr "admin.auths.tip.twitter"}}</span> <span>{{.i18n.Tr "admin.auths.tip.twitter"}}</span>
<li>Discord</li>
<span>{{.i18n.Tr "admin.auths.tip.discord"}}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@ Package gothic wraps common behaviour when using Goth. This makes it quick, and
and running with Goth. Of course, if you want complete control over how things flow, in regards and running with Goth. Of course, if you want complete control over how things flow, in regards
to the authentication process, feel free and use Goth directly. to the authentication process, feel free and use Goth directly.
See https://github.com/markbates/goth/examples/main.go to see this in action. See https://github.com/markbates/goth/blob/master/examples/main.go to see this in action.
*/ */
package gothic package gothic

View File

@ -0,0 +1,210 @@
// Package discord implements the OAuth2 protocol for authenticating users through Discord.
// This package can be used as a reference implementation of an OAuth2 provider for Discord.
package discord
import (
"bytes"
"encoding/json"
"io"
"io/ioutil"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"fmt"
"net/http"
)
const (
authURL string = "https://discordapp.com/api/oauth2/authorize"
tokenURL string = "https://discordapp.com/api/oauth2/token"
userEndpoint string = "https://discordapp.com/api/users/@me"
)
const (
// allows /users/@me without email
ScopeIdentify string = "identify"
// enables /users/@me to return an email
ScopeEmail string = "email"
// allows /users/@me/connections to return linked Twitch and YouTube accounts
ScopeConnections string = "connections"
// allows /users/@me/guilds to return basic information about all of a user's guilds
ScopeGuilds string = "guilds"
// allows /invites/{invite.id} to be used for joining a user's guild
ScopeJoinGuild string = "guilds.join"
// allows your app to join users to a group dm
ScopeGroupDMjoin string = "gdm.join"
// for oauth2 bots, this puts the bot in the user's selected guild by default
ScopeBot string = "bot"
// this generates a webhook that is returned in the oauth token response for authorization code grants
ScopeWebhook string = "webhook.incoming"
)
// New creates a new Discord provider, and sets up important connection details.
// You should always call `discord.New` to get a new Provider. Never try to create
// one manually.
func New(clientKey string, secret string, callbackURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "discord",
}
p.config = newConfig(p, scopes)
return p
}
// Provider is the implementation of `goth.Provider` for accessing Discord
type Provider struct {
ClientKey string
Secret string
CallbackURL string
HTTPClient *http.Client
config *oauth2.Config
providerName string
}
// Name gets the name used to retrieve this provider.
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 no-op for the Discord package.
func (p *Provider) Debug(debug bool) {}
// BeginAuth asks Discord for an authentication end-point.
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
url := p.config.AuthCodeURL(state, oauth2.AccessTypeOnline)
s := &Session{
AuthURL: url,
}
return s, nil
}
// FetchUser will go to Discord and access basic info about the user.
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
s := session.(*Session)
user := goth.User{
AccessToken: s.AccessToken,
Provider: p.Name(),
RefreshToken: s.RefreshToken,
ExpiresAt: s.ExpiresAt,
}
if user.AccessToken == "" {
// data is not yet retrieved since accessToken is still empty
return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
}
req, err := http.NewRequest("GET", userEndpoint, nil)
if err != nil {
return user, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+s.AccessToken)
resp, err := p.Client().Do(req)
if err != nil {
if resp != nil {
resp.Body.Close()
}
return user, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return user, fmt.Errorf("%s responded with a %d trying to fetch user information", p.providerName, resp.StatusCode)
}
bits, err := ioutil.ReadAll(resp.Body)
if err != nil {
return user, err
}
err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData)
if err != nil {
return user, err
}
err = userFromReader(bytes.NewReader(bits), &user)
if err != nil {
return user, err
}
return user, err
}
func userFromReader(r io.Reader, user *goth.User) error {
u := struct {
Name string `json:"username"`
Email string `json:"email"`
AvatarID string `json:"avatar"`
MFAEnabled bool `json:"mfa_enabled"`
Discriminator string `json:"discriminator"`
Verified bool `json:"verified"`
ID string `json:"id"`
}{}
err := json.NewDecoder(r).Decode(&u)
if err != nil {
return err
}
user.Name = u.Name
user.Email = u.Email
user.AvatarURL = "https://media.discordapp.net/avatars/" + u.ID + "/" + u.AvatarID + ".jpg"
user.UserID = u.ID
return nil
}
func newConfig(p *Provider, scopes []string) *oauth2.Config {
c := &oauth2.Config{
ClientID: p.ClientKey,
ClientSecret: p.Secret,
RedirectURL: p.CallbackURL,
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
Scopes: []string{},
}
if len(scopes) > 0 {
for _, scope := range scopes {
c.Scopes = append(c.Scopes, scope)
}
} else {
c.Scopes = []string{ScopeIdentify}
}
return c
}
//RefreshTokenAvailable refresh token is provided by auth provider or not
func (p *Provider) RefreshTokenAvailable() bool {
return true
}
//RefreshToken get new access token based on the refresh token
func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
token := &oauth2.Token{RefreshToken: refreshToken}
ts := p.config.TokenSource(oauth2.NoContext, token)
newToken, err := ts.Token()
if err != nil {
return nil, err
}
return newToken, err
}

View File

@ -0,0 +1,65 @@
package discord
import (
"encoding/json"
"errors"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"strings"
"time"
)
// Session stores data during the auth process with Discord
type Session struct {
AuthURL string
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
// GetAuthURL will return the URL set by calling the `BeginAuth` function on
// the Discord provider.
func (s Session) GetAuthURL() (string, error) {
if s.AuthURL == "" {
return "", errors.New(goth.NoAuthUrlErrorMessage)
}
return s.AuthURL, nil
}
// Authorize completes the authorization with Discord and returns the access
// token to be stored for future use.
func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
p := provider.(*Provider)
token, err := p.config.Exchange(oauth2.NoContext, params.Get("code"))
if err != nil {
return "", err
}
if !token.Valid() {
return "", errors.New("Invalid token received from provider")
}
s.AccessToken = token.AccessToken
s.RefreshToken = token.RefreshToken
s.ExpiresAt = token.Expiry
return token.AccessToken, err
}
// Marshal marshals a session into a JSON string.
func (s Session) Marshal() string {
j, _ := json.Marshal(s)
return string(j)
}
// String is equivalent to Marshal. It returns a JSON representation of the
// of the session.
func (s Session) String() string {
return s.Marshal()
}
// UnmarshalSession will unmarshal a JSON string into a session.
func (p *Provider) UnmarshalSession(data string) (goth.Session, error) {
s := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(s)
return s, err
}

View File

@ -37,6 +37,7 @@ func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
providerName: "facebook", providerName: "facebook",
} }
p.config = newConfig(p, scopes) p.config = newConfig(p, scopes)
p.Fields = "email,first_name,last_name,link,about,id,name,picture,location"
return p return p
} }
@ -46,6 +47,7 @@ type Provider struct {
Secret string Secret string
CallbackURL string CallbackURL string
HTTPClient *http.Client HTTPClient *http.Client
Fields string
config *oauth2.Config config *oauth2.Config
providerName string providerName string
} }
@ -60,6 +62,16 @@ func (p *Provider) SetName(name string) {
p.providerName = name p.providerName = name
} }
// SetCustomFields sets the fields used to return information
// for a user.
//
// A list of available field values can be found at
// https://developers.facebook.com/docs/graph-api/reference/user
func (p *Provider) SetCustomFields(fields []string) *Provider {
p.Fields = strings.Join(fields, ",")
return p
}
func (p *Provider) Client() *http.Client { func (p *Provider) Client() *http.Client {
return goth.HTTPClientWithFallBack(p.HTTPClient) return goth.HTTPClientWithFallBack(p.HTTPClient)
} }
@ -99,7 +111,7 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
reqUrl := fmt.Sprint( reqUrl := fmt.Sprint(
endpointProfile, endpointProfile,
strings.Join(p.config.Scopes, ","), p.Fields,
"&access_token=", "&access_token=",
url.QueryEscape(sess.AccessToken), url.QueryEscape(sess.AccessToken),
"&appsecret_proof=", "&appsecret_proof=",
@ -177,31 +189,17 @@ func newConfig(provider *Provider, scopes []string) *oauth2.Config {
}, },
Scopes: []string{ Scopes: []string{
"email", "email",
"first_name",
"last_name",
"link",
"about",
"id",
"name",
"picture",
"location",
}, },
} }
// creates possibility to invoke field method like 'picture.type(large)' defaultScopes := map[string]struct{}{
var found bool "email": {},
for _, sc := range scopes { }
sc := sc
for i, defScope := range c.Scopes { for _, scope := range scopes {
if defScope == strings.Split(sc, ".")[0] { if _, exists := defaultScopes[scope]; !exists {
c.Scopes[i] = sc c.Scopes = append(c.Scopes, scope)
found = true
}
} }
if !found {
c.Scopes = append(c.Scopes, sc)
}
found = false
} }
return c return c