diff --git a/config.yaml b/config.yaml index 48fade1..968116d 100644 --- a/config.yaml +++ b/config.yaml @@ -62,6 +62,18 @@ providers: app_name: "" app_pass: "" + # Authentication through OAuth2 workflow with Google Account + # Supports: Users + google_oauth: + client_id: "" + client_secret: "" + redirect_url: "https://login.luifer.io/login" + + # Optional, defaults to no limitations + require_domain: "example.com" + # Optional, defaults to "user-id" + user_id_method: "full-email" + # Authentication against (Open)LDAP server # Supports: Users, Groups ldap: diff --git a/core.go b/core.go new file mode 100644 index 0000000..4cae8c9 --- /dev/null +++ b/core.go @@ -0,0 +1,7 @@ +package main + +import "github.com/Luzifer/nginx-sso/plugins/auth/google" + +func registerModules() { + registerAuthenticator(google.New(cookieStore)) +} diff --git a/frontend/index.html b/frontend/index.html index 9c3d21b..350d920 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -56,8 +56,18 @@ {% for field in fields %}
- +
{% endfor %} diff --git a/main.go b/main.go index f133e1e..3159b01 100644 --- a/main.go +++ b/main.go @@ -108,12 +108,13 @@ func loadConfiguration() error { } func main() { + cookieStore = sessions.NewCookieStore([]byte(mainCfg.Cookie.AuthKey)) + registerModules() + if err := loadConfiguration(); err != nil { log.WithError(err).Fatal("Unable to load configuration") } - cookieStore = sessions.NewCookieStore([]byte(mainCfg.Cookie.AuthKey)) - http.HandleFunc("/", handleRootRequest) http.HandleFunc("/auth", handleAuthRequest) http.HandleFunc("/login", handleLoginRequest) @@ -187,7 +188,7 @@ func handleLoginRequest(res http.ResponseWriter, r *http.Request) { "go": redirURL, } - if r.Method == "POST" { + if r.Method == "POST" || r.URL.Query().Get("code") != "" { // Simple authentication user, mfaCfgs, err := loginUser(res, r) switch err { @@ -233,6 +234,17 @@ func handleLoginRequest(res http.ResponseWriter, r *http.Request) { } } + // Store redirect URL in session (required for oAuth2 flows) + sess, _ := cookieStore.Get(r, strings.Join([]string{mainCfg.Cookie.Prefix, "main"}, "-")) // #nosec G104 - On error empty session is returned + sess.Options = mainCfg.Cookie.GetSessionOpts() + sess.Values["go"] = redirURL + + if err := sess.Save(r, res); err != nil { + log.WithError(err).Error("Unable to save session") + http.Error(res, "Something went wrong", http.StatusInternalServerError) + } + + // Render login page tpl := pongo2.Must(pongo2.FromFile(path.Join(cfg.TemplateDir, "index.html"))) if err := tpl.ExecuteWriter(pongo2.Context{ "active_methods": getFrontendAuthenticators(), diff --git a/plugins/auth.go b/plugins/auth.go index c9680f8..2f567ec 100644 --- a/plugins/auth.go +++ b/plugins/auth.go @@ -48,6 +48,7 @@ type Authenticator interface { } type LoginField struct { + Action string Label string Name string Placeholder string diff --git a/plugins/auth/google/auth.go b/plugins/auth/google/auth.go new file mode 100644 index 0000000..ab6805a --- /dev/null +++ b/plugins/auth/google/auth.go @@ -0,0 +1,237 @@ +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.v2" + + "github.com/gorilla/sessions" + "github.com/pkg/errors" + + "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"` + 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"` + }{} + + 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.RequireDomain = envelope.Providers.GoogleOAuth.RequireDomain + 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()) + + 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 instanciate OAuth2 API service") + } + + tok, err := client.Tokeninfo().Context(ctx).Do() + if err != nil { + return "", errors.Wrap(err, "Unable to fetch token-info") + } + + if a.RequireDomain != "" && !strings.HasSuffix(tok.Email, "@"+a.RequireDomain) { + // 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) + } +}