mirror of
https://github.com/volatiletech/authboss.git
synced 2025-02-09 13:47:09 +02:00
Add initial oauth2 support.
- Needs more providers and more tests.
This commit is contained in:
parent
dccabb0754
commit
538adcf188
@ -11,6 +11,8 @@ const (
|
||||
SessionHalfAuthKey = "halfauth"
|
||||
// SessionLastAction is the session key to retrieve the last action of a user.
|
||||
SessionLastAction = "last_action"
|
||||
// SessionOAuth2State is the xsrf protection key for oauth.
|
||||
SessionOAuth2State = "oauth2.state"
|
||||
|
||||
// CookieRemember is used for cookies and form input names.
|
||||
CookieRemember = "rm"
|
||||
|
@ -34,6 +34,8 @@ type Config struct {
|
||||
LayoutTextEmail *template.Template
|
||||
LayoutDataMaker ViewDataMaker
|
||||
|
||||
OAuth2Providers map[string]OAuthProvider
|
||||
|
||||
// ErrorHandler handles would be 500 errors.
|
||||
ErrorHandler http.Handler
|
||||
// BadRequestHandler handles would be 400 errors.
|
||||
|
25
oauth2.go
Normal file
25
oauth2.go
Normal file
@ -0,0 +1,25 @@
|
||||
package authboss
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// OAuth2Provider is the entire configuration
|
||||
// required to authenticate with this provider.
|
||||
//
|
||||
// The OAuth2Config does not need a redirect URL because it will
|
||||
// be automatically created by the
|
||||
type OAuthProvider struct {
|
||||
OAuth2Config *oauth2.Config
|
||||
AdditionalParams url.Values
|
||||
Callback func(oauth2.Config, *oauth2.Token) (OAuth2Credentials, error)
|
||||
}
|
||||
|
||||
// OAuth2Credentials are used to store in the database.
|
||||
// Email is optional
|
||||
type OAuth2Credentials struct {
|
||||
UID string
|
||||
Email string
|
||||
}
|
164
oauth2/oauth2.go
Normal file
164
oauth2/oauth2.go
Normal file
@ -0,0 +1,164 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"golang.org/x/net/context"
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/authboss.v0"
|
||||
)
|
||||
|
||||
// OAuth2Storer is required to do OAuth2 storing.
|
||||
type OAuth2Storer interface {
|
||||
authboss.Storer
|
||||
// NewOrUpdate should retrieve the user if he already exists, or create
|
||||
// a new one. The key is composed of the provider:UID together and is stored
|
||||
// in the authboss.StoreUsername field.
|
||||
OAuth2NewOrUpdate(key string, attr authboss.Attributes) error
|
||||
}
|
||||
|
||||
type OAuth2 struct{}
|
||||
|
||||
func init() {
|
||||
authboss.RegisterModule("oauth2", &OAuth2{})
|
||||
}
|
||||
|
||||
func (o *OAuth2) Initialize() error {
|
||||
if _, ok := authboss.Cfg.Storer.(OAuth2Storer); !ok {
|
||||
return errors.New("oauth2: need an OAuth2Storer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OAuth2) Routes() authboss.RouteTable {
|
||||
routes := make(authboss.RouteTable)
|
||||
|
||||
for prov, cfg := range authboss.Cfg.OAuth2Providers {
|
||||
prov = strings.ToLower(prov)
|
||||
|
||||
init := fmt.Sprintf("/oauth2/%s", prov)
|
||||
callback := fmt.Sprintf("/oauth2/callback/%s", prov)
|
||||
|
||||
routes[init] = oauthInit
|
||||
routes[callback] = oauthCallback
|
||||
|
||||
if len(authboss.Cfg.MountPath) > 0 {
|
||||
callback = path.Join(authboss.Cfg.MountPath, callback)
|
||||
}
|
||||
cfg.OAuth2Config.RedirectURL = authboss.Cfg.RootURL + callback
|
||||
}
|
||||
|
||||
return routes
|
||||
}
|
||||
|
||||
func (o *OAuth2) Storage() authboss.StorageOptions {
|
||||
return authboss.StorageOptions{
|
||||
authboss.StoreUsername: authboss.String,
|
||||
authboss.StoreEmail: authboss.String,
|
||||
authboss.StoreOAuth2Token: authboss.String,
|
||||
authboss.StoreOAuth2Refresh: authboss.String,
|
||||
authboss.StoreOAuth2Expiry: authboss.DateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func oauthInit(ctx *authboss.Context, w http.ResponseWriter, r *http.Request) error {
|
||||
provider := strings.ToLower(filepath.Base(r.URL.Path))
|
||||
cfg, ok := authboss.Cfg.OAuth2Providers[provider]
|
||||
if !ok {
|
||||
return fmt.Errorf("OAuth2 provider %q not found", provider)
|
||||
}
|
||||
|
||||
random := make([]byte, 32)
|
||||
_, err := rand.Read(random)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state := base64.URLEncoding.EncodeToString(random)
|
||||
ctx.SessionStorer.Put(authboss.SessionOAuth2State, state)
|
||||
|
||||
url := cfg.OAuth2Config.AuthCodeURL(state)
|
||||
|
||||
extraParams := cfg.AdditionalParams.Encode()
|
||||
if len(extraParams) > 0 {
|
||||
url = fmt.Sprintf("%s&%s", url, extraParams)
|
||||
}
|
||||
|
||||
http.Redirect(w, r, url, http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
func oauthCallback(ctx *authboss.Context, w http.ResponseWriter, r *http.Request) error {
|
||||
provider := strings.ToLower(filepath.Base(r.URL.Path))
|
||||
|
||||
hasErr := r.FormValue("error")
|
||||
if len(hasErr) > 0 {
|
||||
return authboss.ErrAndRedirect{
|
||||
Err: errors.New(r.FormValue("error_reason")),
|
||||
Location: authboss.Cfg.AuthLoginFailPath,
|
||||
FlashError: fmt.Sprintf("%s login cancelled or failed.", strings.Title(provider)),
|
||||
}
|
||||
}
|
||||
|
||||
sessState, err := ctx.SessionStorer.GetErr(authboss.SessionOAuth2State)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, ok := authboss.Cfg.OAuth2Providers[provider]
|
||||
if !ok {
|
||||
return fmt.Errorf("OAuth2 provider %q not found", provider)
|
||||
}
|
||||
|
||||
// Ensure request is genuine
|
||||
state := r.FormValue("state")
|
||||
if state != sessState {
|
||||
return errors.New("Could not validate oauth2 state param")
|
||||
}
|
||||
|
||||
// Get the code
|
||||
code := r.FormValue("code")
|
||||
oauthCtx := context.WithValue(nil, oauth2.HTTPClient, nil)
|
||||
token, err := cfg.OAuth2Config.Exchange(oauthCtx, code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not validate oauth2 code: %v", err)
|
||||
}
|
||||
|
||||
// User is authenticated
|
||||
ctx.User[authboss.StoreOAuth2Expiry] = token.Expiry
|
||||
ctx.User[authboss.StoreOAuth2Token] = token.AccessToken
|
||||
if len(token.RefreshToken) != 0 {
|
||||
ctx.User[authboss.StoreOAuth2Refresh] = token.RefreshToken
|
||||
}
|
||||
|
||||
spew.Dump(token)
|
||||
|
||||
credentials, err := cfg.Callback(*cfg.OAuth2Config, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s:%s", provider, credentials.UID)
|
||||
ctx.User[authboss.StoreUsername] = key
|
||||
if len(credentials.Email) > 0 {
|
||||
ctx.User[authboss.StoreEmail] = credentials.Email
|
||||
}
|
||||
|
||||
ctx.SessionStorer.Put(authboss.SessionKey, key)
|
||||
|
||||
storer := authboss.Cfg.Storer.(OAuth2Storer)
|
||||
if err = storer.OAuth2NewOrUpdate(key, ctx.User); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
http.Redirect(w, r, authboss.Cfg.AuthLoginOKPath, http.StatusFound)
|
||||
return nil
|
||||
}
|
99
oauth2/oauth2_test.go
Normal file
99
oauth2/oauth2_test.go
Normal file
@ -0,0 +1,99 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/authboss.v0"
|
||||
"gopkg.in/authboss.v0/internal/mocks"
|
||||
)
|
||||
|
||||
var testAddress = "localhost:23232"
|
||||
|
||||
var testProviders = map[string]authboss.OAuthProvider{
|
||||
"google": authboss.OAuthProvider{
|
||||
OAuth2Config: &oauth2.Config{
|
||||
ClientID: `jazz`,
|
||||
ClientSecret: `hands`,
|
||||
Scopes: []string{`profile`, `email`},
|
||||
Endpoint: GoogleEndpoint,
|
||||
},
|
||||
Callback: Google,
|
||||
AdditionalParams: url.Values{"include_requested_scopes": []string{"true"}},
|
||||
},
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
/*listener, err := net.Listen(testAddress)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/oauth_init_success", func(w http.ResponseWriter, r *http.Request) {
|
||||
vals := url.Values{
|
||||
"code": "test",
|
||||
}
|
||||
io.WriteString(w, vals.Encode())
|
||||
})
|
||||
mux.HandleFunc("/oauth_init_fail", func(w http.ResponseWriter, r *http.Request) {
|
||||
vals := url.Values{
|
||||
"error": "error",
|
||||
"error_reason": "access_denied",
|
||||
"error_description": "The user denied your request.",
|
||||
}
|
||||
io.WriteString(w, vals.Encode())
|
||||
})
|
||||
mux.HandleFunc("/oauth_token", func(w http.ResponseWriter, r *http.Request) {
|
||||
vals := url.Values{
|
||||
"access_token": "ya29.MgEXfCc5ipyWWEXxcyR0fV7oqlbHQ1xQTDARQlciDYoWlQB72VTgsTeD-8diiB_2cxaXEGMvEpvhZQ",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
}
|
||||
io.WriteString(w, vals.Encode())
|
||||
})
|
||||
go http.Serve(listener, mux)*/
|
||||
|
||||
code := m.Run()
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestOAuth2Init(t *testing.T) {
|
||||
cfg := authboss.NewConfig()
|
||||
session := mocks.NewMockClientStorer()
|
||||
|
||||
cfg.OAuth2Providers = testProviders
|
||||
authboss.Cfg = cfg
|
||||
|
||||
r, _ := http.NewRequest("GET", "/oauth2/google", nil)
|
||||
w := httptest.NewRecorder()
|
||||
ctx := authboss.NewContext()
|
||||
ctx.SessionStorer = session
|
||||
|
||||
oauthInit(ctx, w, r)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Error("Code was wrong:", w.Code)
|
||||
}
|
||||
|
||||
loc := w.Header().Get("Location")
|
||||
parsed, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if !strings.Contains(loc, GoogleEndpoint.AuthURL) {
|
||||
t.Error("Redirected to wrong url:", loc)
|
||||
}
|
||||
|
||||
query := parsed.Query()
|
||||
if query["include_requested_scopes"][0] != "true" {
|
||||
t.Error("Missing extra parameters:", loc)
|
||||
}
|
||||
}
|
42
oauth2/providers.go
Normal file
42
oauth2/providers.go
Normal file
@ -0,0 +1,42 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/authboss.v0"
|
||||
)
|
||||
|
||||
var (
|
||||
// GoogleEndpoint can be used to
|
||||
GoogleEndpoint = oauth2.Endpoint{
|
||||
AuthURL: `https://accounts.google.com/o/oauth2/auth`,
|
||||
TokenURL: `https://accounts.google.com/o/oauth2/token`,
|
||||
}
|
||||
googleInfoEndpoint = `https://www.googleapis.com/userinfo/v2/me`
|
||||
)
|
||||
|
||||
type googleMeResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// Google is a callback appropriate for use with Google's OAuth2 configuration.
|
||||
func Google(cfg oauth2.Config, token *oauth2.Token) (cred authboss.OAuth2Credentials, err error) {
|
||||
client := cfg.Client(oauth2.NoContext, token)
|
||||
resp, err := client.Get(googleInfoEndpoint)
|
||||
if err != nil {
|
||||
return cred, err
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
var jsonResp googleMeResponse
|
||||
if err = dec.Decode(&jsonResp); err != nil {
|
||||
return cred, err
|
||||
}
|
||||
|
||||
cred.UID = jsonResp.ID
|
||||
cred.Email = jsonResp.Email
|
||||
return cred, nil
|
||||
}
|
@ -18,6 +18,13 @@ const (
|
||||
StorePassword = "password"
|
||||
)
|
||||
|
||||
// Data store constants for OAuth2 attribute names.
|
||||
const (
|
||||
StoreOAuth2Token = "oauth2_token"
|
||||
StoreOAuth2Refresh = "oauth2_refresh"
|
||||
StoreOAuth2Expiry = "oauth2_expiry"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUserNotFound should be returned from Get when the record is not found.
|
||||
ErrUserNotFound = errors.New("User not found")
|
||||
|
Loading…
x
Reference in New Issue
Block a user