mirror of
https://github.com/volatiletech/authboss.git
synced 2026-06-19 23:00:27 +02:00
Add initial oauth2 support.
- Needs more providers and more tests.
This commit is contained in:
@@ -11,6 +11,8 @@ const (
|
|||||||
SessionHalfAuthKey = "halfauth"
|
SessionHalfAuthKey = "halfauth"
|
||||||
// SessionLastAction is the session key to retrieve the last action of a user.
|
// SessionLastAction is the session key to retrieve the last action of a user.
|
||||||
SessionLastAction = "last_action"
|
SessionLastAction = "last_action"
|
||||||
|
// SessionOAuth2State is the xsrf protection key for oauth.
|
||||||
|
SessionOAuth2State = "oauth2.state"
|
||||||
|
|
||||||
// CookieRemember is used for cookies and form input names.
|
// CookieRemember is used for cookies and form input names.
|
||||||
CookieRemember = "rm"
|
CookieRemember = "rm"
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ type Config struct {
|
|||||||
LayoutTextEmail *template.Template
|
LayoutTextEmail *template.Template
|
||||||
LayoutDataMaker ViewDataMaker
|
LayoutDataMaker ViewDataMaker
|
||||||
|
|
||||||
|
OAuth2Providers map[string]OAuthProvider
|
||||||
|
|
||||||
// ErrorHandler handles would be 500 errors.
|
// ErrorHandler handles would be 500 errors.
|
||||||
ErrorHandler http.Handler
|
ErrorHandler http.Handler
|
||||||
// BadRequestHandler handles would be 400 errors.
|
// BadRequestHandler handles would be 400 errors.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
StorePassword = "password"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Data store constants for OAuth2 attribute names.
|
||||||
|
const (
|
||||||
|
StoreOAuth2Token = "oauth2_token"
|
||||||
|
StoreOAuth2Refresh = "oauth2_refresh"
|
||||||
|
StoreOAuth2Expiry = "oauth2_expiry"
|
||||||
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// ErrUserNotFound should be returned from Get when the record is not found.
|
// ErrUserNotFound should be returned from Get when the record is not found.
|
||||||
ErrUserNotFound = errors.New("User not found")
|
ErrUserNotFound = errors.New("User not found")
|
||||||
|
|||||||
Reference in New Issue
Block a user