1
0
Fork 0
mirror of https://github.com/Luzifer/nginx-sso.git synced 2024-12-30 01:31:18 +00:00
nginx-sso/plugins/auth/google/auth.go

263 lines
7.7 KiB
Go
Raw Normal View History

package google
import (
"context"
"encoding/gob"
"fmt"
"net/http"
"strings"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
v2 "google.golang.org/api/oauth2/v2"
"google.golang.org/api/option"
yaml "gopkg.in/yaml.v3"
"github.com/gorilla/sessions"
"github.com/pkg/errors"
"github.com/Luzifer/go_helpers/v2/str"
"github.com/Luzifer/nginx-sso/plugins"
)
const (
userIDMethodFullEmail = "full-email"
userIDMethodLocalPart = "local-part"
userIDMethodUserID = "user-id"
)
type AuthGoogleOAuth struct {
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
RedirectURL string `yaml:"redirect_url"`
RequireDomain string `yaml:"require_domain"` // Deprecated: Use RequireDomains
RequireDomains []string `yaml:"require_domains"`
UserIDMethod string `yaml:"user_id_method"`
cookie plugins.CookieConfig
cookieStore *sessions.CookieStore
}
func init() {
gob.Register(&oauth2.Token{})
}
func New(cs *sessions.CookieStore) *AuthGoogleOAuth {
return &AuthGoogleOAuth{
UserIDMethod: userIDMethodUserID,
cookieStore: cs,
}
}
// AuthenticatorID needs to return an unique string to identify
// this special authenticator
func (a *AuthGoogleOAuth) AuthenticatorID() (id string) { return "google_oauth" }
// Configure loads the configuration for the Authenticator from the
// global config.yaml file which is passed as a byte-slice.
// If no configuration for the Authenticator is supplied the function
// needs to return the ErrProviderUnconfigured
func (a *AuthGoogleOAuth) Configure(yamlSource []byte) (err error) {
envelope := struct {
Cookie plugins.CookieConfig `yaml:"cookie"`
Providers struct {
GoogleOAuth *AuthGoogleOAuth `yaml:"google_oauth"`
} `yaml:"providers"`
}{}
envelope.Cookie = plugins.DefaultCookieConfig()
if err := yaml.Unmarshal(yamlSource, &envelope); err != nil {
return err
}
if envelope.Providers.GoogleOAuth == nil {
return plugins.ErrProviderUnconfigured
}
a.ClientID = envelope.Providers.GoogleOAuth.ClientID
a.ClientSecret = envelope.Providers.GoogleOAuth.ClientSecret
a.RedirectURL = envelope.Providers.GoogleOAuth.RedirectURL
a.RequireDomains = envelope.Providers.GoogleOAuth.RequireDomains
if len(envelope.Providers.GoogleOAuth.RequireDomain) > 0 {
// Migration for old configuration with only single require_domain
a.RequireDomains = append(
a.RequireDomains,
envelope.Providers.GoogleOAuth.RequireDomain,
)
}
if envelope.Providers.GoogleOAuth.UserIDMethod != "" {
a.UserIDMethod = envelope.Providers.GoogleOAuth.UserIDMethod
}
a.cookie = envelope.Cookie
return nil
}
// DetectUser is used to detect a user without a login form from
// a cookie, header or other methods
// If no user was detected the ErrNoValidUserFound needs to be
// returned
func (a *AuthGoogleOAuth) DetectUser(res http.ResponseWriter, r *http.Request) (user string, groups []string, err error) {
sess, err := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-"))
if err != nil {
return "", nil, plugins.ErrNoValidUserFound
}
token, ok := sess.Values["google_token"].(*oauth2.Token)
if !ok {
return "", nil, plugins.ErrNoValidUserFound
}
u, err := a.getUserFromToken(r.Context(), token)
if err != nil {
if err == plugins.ErrNoValidUserFound {
return "", nil, err
}
return "", nil, errors.Wrap(err, "Unable to fetch user info")
}
// We had a cookie, lets renew it
sess.Options = a.cookie.GetSessionOpts()
if err := sess.Save(r, res); err != nil {
return "", nil, err
}
return u, nil, nil // TODO: Maybe get group info?
}
// Login is called when the user submits the login form and needs
// to authenticate the user or throw an error. If the user has
// successfully logged in the persistent cookie should be written
// in order to use DetectUser for the next login.
// With the login result an array of mfaConfig must be returned. In
// case there is no MFA config or the provider does not support MFA
// return nil.
// If the user did not login correctly the ErrNoValidUserFound
// needs to be returned
func (a *AuthGoogleOAuth) Login(res http.ResponseWriter, r *http.Request) (user string, mfaConfigs []plugins.MFAConfig, err error) {
var (
code = r.URL.Query().Get("code")
state = r.URL.Query().Get("state")
u string
)
if code == "" || state != a.AuthenticatorID() {
return "", nil, plugins.ErrNoValidUserFound
}
token, err := a.getOAuthConfig().Exchange(r.Context(), code)
if err != nil {
return "", nil, errors.Wrap(err, "Unable to exchange token")
}
u, err = a.getUserFromToken(r.Context(), token)
if err != nil {
if err == plugins.ErrNoValidUserFound {
return "", nil, err
}
return "", nil, errors.Wrap(err, "Unable to fetch user info")
}
sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned
sess.Options = a.cookie.GetSessionOpts()
sess.Values["google_token"] = token
return u, nil, sess.Save(r, res)
}
// LoginFields needs to return the fields required for this login
// method. If no login using this method is possible the function
// needs to return nil.
func (a *AuthGoogleOAuth) LoginFields() (fields []plugins.LoginField) {
loginURL := a.getOAuthConfig().AuthCodeURL(
a.AuthenticatorID(),
oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("prompt", "consent"),
)
return []plugins.LoginField{
{
Action: fmt.Sprintf("window.location.href='%s'", loginURL),
Label: "Trigger Login",
Name: "button",
Placeholder: "Sign in with Google",
Type: "button",
},
}
}
// Logout is called when the user visits the logout endpoint and
// needs to destroy any persistent stored cookies
func (a *AuthGoogleOAuth) Logout(res http.ResponseWriter, r *http.Request) (err error) {
sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned
sess.Options = a.cookie.GetSessionOpts()
sess.Options.MaxAge = -1 // Instant delete
return sess.Save(r, res)
}
// SupportsMFA returns the MFA detection capabilities of the login
// provider. If the provider can provide mfaConfig objects from its
// configuration return true. If this is true the login interface
// will display an additional field for this provider for the user
// to fill in their MFA token.
func (a *AuthGoogleOAuth) SupportsMFA() bool { return false }
func (a *AuthGoogleOAuth) getOAuthConfig() *oauth2.Config {
return &oauth2.Config{
ClientID: a.ClientID,
ClientSecret: a.ClientSecret,
Endpoint: google.Endpoint,
RedirectURL: a.RedirectURL,
Scopes: []string{
v2.UserinfoEmailScope,
v2.UserinfoProfileScope,
},
}
}
func (a *AuthGoogleOAuth) getUserFromToken(ctx context.Context, token *oauth2.Token) (string, error) {
conf := a.getOAuthConfig()
httpClient := conf.Client(ctx, token)
client, err := v2.NewService(ctx, option.WithHTTPClient(httpClient))
if err != nil {
return "", errors.Wrap(err, "Unable to instantiate OAuth2 API service")
}
tok, err := client.Tokeninfo().Context(ctx).Do()
if err != nil {
return "", errors.Wrap(err, "Unable to fetch token-info")
}
var mailParts = strings.Split(tok.Email, "@")
if len(mailParts) != 2 {
return "", errors.New("Invalid email returned")
}
if len(a.RequireDomains) > 0 && !str.StringInSlice(mailParts[1], a.RequireDomains) {
// E-Mail domain is enforced, ignore all other users
return "", plugins.ErrNoValidUserFound
}
switch a.UserIDMethod {
case userIDMethodFullEmail:
return tok.Email, nil
case userIDMethodLocalPart:
return strings.Split(tok.Email, "@")[0], nil
case "":
fallthrough
case userIDMethodUserID:
return tok.UserId, nil
default:
return "", errors.Errorf("Invalid user_id_method %q", a.UserIDMethod)
}
}