1
0
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:
Aaron 2015-03-12 19:20:36 -07:00
parent dccabb0754
commit 538adcf188
7 changed files with 341 additions and 0 deletions

View File

@ -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"

View File

@ -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
View 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
View 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
View 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
View 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
}

View File

@ -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")