mirror of
https://github.com/Luzifer/nginx-sso.git
synced 2024-12-20 12:51:17 +00:00
Implement oAuth2 provider: Google
fixes #15 refs #35 Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
841f6458cd
commit
42db8e247d
6 changed files with 284 additions and 5 deletions
12
config.yaml
12
config.yaml
|
@ -62,6 +62,18 @@ providers:
|
||||||
app_name: ""
|
app_name: ""
|
||||||
app_pass: ""
|
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
|
# Authentication against (Open)LDAP server
|
||||||
# Supports: Users, Groups
|
# Supports: Users, Groups
|
||||||
ldap:
|
ldap:
|
||||||
|
|
7
core.go
Normal file
7
core.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/Luzifer/nginx-sso/plugins/auth/google"
|
||||||
|
|
||||||
|
func registerModules() {
|
||||||
|
registerAuthenticator(google.New(cookieStore))
|
||||||
|
}
|
|
@ -56,8 +56,18 @@
|
||||||
{% for field in fields %}
|
{% for field in fields %}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ method }}-{{ field.Name }}">{{ field.Label }}</label>
|
<label for="{{ method }}-{{ field.Name }}">{{ field.Label }}</label>
|
||||||
<input type="{{ field.Type }}" class="form-control" placeholder="{{ field.Placeholder }}"
|
<input
|
||||||
name="{{ method }}-{{ field.Name }}" id="{{ method }}-{{ field.Name }}" />
|
class="form-control"
|
||||||
|
id="{{ method }}-{{ field.Name }}"
|
||||||
|
name="{{ method }}-{{ field.Name }}"
|
||||||
|
{% if field.Action %}onclick="{{ field.Action }}"{% endif %}
|
||||||
|
{% if field.Type == "button" %}
|
||||||
|
value="{{ field.Placeholder }}"
|
||||||
|
{% else %}
|
||||||
|
placeholder="{{ field.Placeholder }}"
|
||||||
|
{% endif %}
|
||||||
|
type="{{ field.Type }}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
|
18
main.go
18
main.go
|
@ -108,12 +108,13 @@ func loadConfiguration() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
cookieStore = sessions.NewCookieStore([]byte(mainCfg.Cookie.AuthKey))
|
||||||
|
registerModules()
|
||||||
|
|
||||||
if err := loadConfiguration(); err != nil {
|
if err := loadConfiguration(); err != nil {
|
||||||
log.WithError(err).Fatal("Unable to load configuration")
|
log.WithError(err).Fatal("Unable to load configuration")
|
||||||
}
|
}
|
||||||
|
|
||||||
cookieStore = sessions.NewCookieStore([]byte(mainCfg.Cookie.AuthKey))
|
|
||||||
|
|
||||||
http.HandleFunc("/", handleRootRequest)
|
http.HandleFunc("/", handleRootRequest)
|
||||||
http.HandleFunc("/auth", handleAuthRequest)
|
http.HandleFunc("/auth", handleAuthRequest)
|
||||||
http.HandleFunc("/login", handleLoginRequest)
|
http.HandleFunc("/login", handleLoginRequest)
|
||||||
|
@ -187,7 +188,7 @@ func handleLoginRequest(res http.ResponseWriter, r *http.Request) {
|
||||||
"go": redirURL,
|
"go": redirURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.Method == "POST" {
|
if r.Method == "POST" || r.URL.Query().Get("code") != "" {
|
||||||
// Simple authentication
|
// Simple authentication
|
||||||
user, mfaCfgs, err := loginUser(res, r)
|
user, mfaCfgs, err := loginUser(res, r)
|
||||||
switch err {
|
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")))
|
tpl := pongo2.Must(pongo2.FromFile(path.Join(cfg.TemplateDir, "index.html")))
|
||||||
if err := tpl.ExecuteWriter(pongo2.Context{
|
if err := tpl.ExecuteWriter(pongo2.Context{
|
||||||
"active_methods": getFrontendAuthenticators(),
|
"active_methods": getFrontendAuthenticators(),
|
||||||
|
|
|
@ -48,6 +48,7 @@ type Authenticator interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginField struct {
|
type LoginField struct {
|
||||||
|
Action string
|
||||||
Label string
|
Label string
|
||||||
Name string
|
Name string
|
||||||
Placeholder string
|
Placeholder string
|
||||||
|
|
237
plugins/auth/google/auth.go
Normal file
237
plugins/auth/google/auth.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue