diff --git a/cmd/wg-portal/common/config.go b/cmd/wg-portal/common/config.go index ed2edaa..82fe1cb 100644 --- a/cmd/wg-portal/common/config.go +++ b/cmd/wg-portal/common/config.go @@ -13,6 +13,9 @@ IsAdmin string } +type LdapAuthProvider struct { +} + type OpenIDConnectProvider struct { // ProviderName is an internal name that is used to distinguish oauth endpoints. It must not contain spaces or special characters. ProviderName string @@ -28,7 +31,7 @@ // ClientSecret is the application's secret. ClientSecret string - Scopes []string + ExtraScopes []string FieldMap OauthFields } @@ -89,6 +92,7 @@ Auth struct { OpenIDConnect []OpenIDConnectProvider `yaml:"openIdCconnect"` OAuth []OAuthProvider `yaml:"oauth"` + Ldap []LdapAuthProvider `yaml:"ldap"` } `yaml:"auth"` Mail portal.MailConfig `yaml:"email"` diff --git a/cmd/wg-portal/common/oauth.go b/cmd/wg-portal/common/oauth.go new file mode 100644 index 0000000..ede5389 --- /dev/null +++ b/cmd/wg-portal/common/oauth.go @@ -0,0 +1,177 @@ +package common + +import ( + "context" + "encoding/json" + "io/ioutil" + "net/http" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/pkg/errors" + + "golang.org/x/oauth2" +) + +type AuthenticatorType string + +const ( + AuthenticatorTypeOAuth AuthenticatorType = "oauth" + AuthenticatorTypeOidc AuthenticatorType = "oidc" +) + +type AuthenticatorUserInfo struct { +} + +type Authenticator interface { + GetType() AuthenticatorType + AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string + Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) + GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (map[string]interface{}, error) + ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error) +} + +type plainOauthAuthenticator struct { + name string + cfg *oauth2.Config + userInfoEndpoint string + client *http.Client + userInfoMapping map[string]string +} + +func NewPlainOauthAuthenticator(_ context.Context, callbackUrl string, cfg *OAuthProvider) (*plainOauthAuthenticator, error) { + var authenticator = &plainOauthAuthenticator{} + + authenticator.name = cfg.ProviderName + authenticator.client = &http.Client{ + Timeout: time.Second * 10, + } + authenticator.cfg = &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: cfg.AuthURL, + TokenURL: cfg.TokenURL, + AuthStyle: oauth2.AuthStyleAutoDetect, + }, + RedirectURL: callbackUrl, + Scopes: cfg.Scopes, + } + authenticator.userInfoEndpoint = cfg.UserInfoURL + + return authenticator, nil +} + +func (p plainOauthAuthenticator) GetType() AuthenticatorType { + return AuthenticatorTypeOAuth +} + +func (p plainOauthAuthenticator) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return p.cfg.AuthCodeURL(state, opts...) +} + +func (p plainOauthAuthenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return p.cfg.Exchange(ctx, code, opts...) +} + +func (p plainOauthAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, _ string) (map[string]interface{}, error) { + req, err := http.NewRequest("GET", p.userInfoEndpoint, nil) + if err != nil { + return nil, errors.WithMessage(err, "failed to create user info get request") + } + req.Header.Add("Authorization", "Bearer "+token.AccessToken) + req.WithContext(ctx) + + response, err := p.client.Do(req) + if err != nil { + return nil, errors.WithMessage(err, "failed to get user info") + } + defer response.Body.Close() + contents, err := ioutil.ReadAll(response.Body) + if err != nil { + return nil, errors.WithMessage(err, "failed to read response body") + } + + var userFields map[string]interface{} + err = json.Unmarshal(contents, &userFields) + if err != nil { + return nil, errors.WithMessage(err, "failed to parse user info") + } + + return userFields, nil +} + +func (p plainOauthAuthenticator) ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error) { + return nil, nil // TODO: implement +} + +type oidcAuthenticator struct { + name string + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + cfg *oauth2.Config + userInfoMapping map[string]string +} + +func NewOidcAuthenticator(ctx context.Context, callbackUrl string, cfg *OpenIDConnectProvider) (*oidcAuthenticator, error) { + var err error + var authenticator = &oidcAuthenticator{} + + authenticator.name = cfg.ProviderName + authenticator.provider, err = oidc.NewProvider(ctx, cfg.BaseUrl) + if err != nil { + return nil, errors.WithMessage(err, "failed to create new oidc provider") + } + authenticator.verifier = authenticator.provider.Verifier(&oidc.Config{ + ClientID: cfg.ClientID, + }) + + scopes := []string{oidc.ScopeOpenID} + scopes = append(scopes, cfg.ExtraScopes...) + authenticator.cfg = &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: authenticator.provider.Endpoint(), + RedirectURL: callbackUrl, + Scopes: scopes, + } + + return authenticator, nil +} + +func (o oidcAuthenticator) GetType() AuthenticatorType { + return AuthenticatorTypeOidc +} + +func (o oidcAuthenticator) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { + return o.cfg.AuthCodeURL(state, opts...) +} + +func (o oidcAuthenticator) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { + return o.cfg.Exchange(ctx, code, opts...) +} + +func (o oidcAuthenticator) GetUserInfo(ctx context.Context, token *oauth2.Token, nonce string) (map[string]interface{}, error) { + rawIDToken, ok := token.Extra("id_token").(string) + if !ok { + return nil, errors.New("token does not contain id_token") + } + idToken, err := o.verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, errors.WithMessage(err, "failed to validate id_token") + } + if idToken.Nonce != nonce { + return nil, errors.New("nonce mismatch") + } + + var tokenFields map[string]interface{} + if err = idToken.Claims(&tokenFields); err != nil { + return nil, errors.WithMessage(err, "failed to parse extra claims") + } + + return tokenFields, nil +} + +func (o oidcAuthenticator) ParseUserInfo(raw map[string]interface{}) (*AuthenticatorUserInfo, error) { + return nil, nil // TODO: implement +} diff --git a/cmd/wg-portal/common/session.go b/cmd/wg-portal/common/session.go index 999325e..22e0162 100644 --- a/cmd/wg-portal/common/session.go +++ b/cmd/wg-portal/common/session.go @@ -12,6 +12,7 @@ } type SessionData struct { + AuthBackend string OauthState string // oauth state OidcNonce string // oidc id token nonce LoggedIn bool diff --git a/cmd/wg-portal/main.go b/cmd/wg-portal/main.go index 9930aff..ef86fea 100644 --- a/cmd/wg-portal/main.go +++ b/cmd/wg-portal/main.go @@ -41,6 +41,7 @@ func entrypoint(ctx context.Context, cancel context.CancelFunc) { defer cancel() // quit program if main entrypoint ends + // default config, TODO: implement cfg := &common.Config{ Database: persistence.DatabaseConfig{ Type: "sqlite", @@ -48,6 +49,7 @@ }, } cfg.Core.ListeningAddress = ":8080" + cfg.Core.ExternalUrl = "http://localhost:8080" cfg.Core.GinDebug = true cfg.Core.LogLevel = "trace" cfg.Core.CompanyName = "Test Company" @@ -58,6 +60,9 @@ ProviderName: "google", DisplayName: "Login with
Google", BaseUrl: "https://accounts.google.com", + ClientID: "XXXX.apps.googleusercontent.com", + ClientSecret: "XXXX", + ExtraScopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}, }, } // TODO: load config diff --git a/cmd/wg-portal/ui/handler.go b/cmd/wg-portal/ui/handler.go index 1486f2c..d497a37 100644 --- a/cmd/wg-portal/ui/handler.go +++ b/cmd/wg-portal/ui/handler.go @@ -4,10 +4,9 @@ "context" "net/url" "path" + "strings" + "time" - "golang.org/x/oauth2" - - "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/h44z/wg-portal/cmd/wg-portal/common" @@ -17,34 +16,23 @@ csrf "github.com/utrack/gin-csrf" ) -type AuthProviderType string - -const ( - AuthProviderTypeOAuth = "oauth" - AuthProviderTypeOpenIDConnect = "oidc" -) - type Handler struct { config *common.Config - backend portal.Backend - authProviderNames map[string]AuthProviderType - oidcProviders map[string]*oidc.Provider - oidcVerifiers map[string]*oidc.IDTokenVerifier - oauthConfigs map[string]*oauth2.Config + backend portal.Backend + oauthAuthenticators map[string]common.Authenticator } func NewHandler(config *common.Config, backend portal.Backend) (*Handler, error) { h := &Handler{ - config: config, - backend: backend, - authProviderNames: make(map[string]AuthProviderType), - oidcProviders: make(map[string]*oidc.Provider), - oidcVerifiers: make(map[string]*oidc.IDTokenVerifier), - oauthConfigs: make(map[string]*oauth2.Config), + config: config, + backend: backend, + oauthAuthenticators: make(map[string]common.Authenticator), } - err := h.setupAuthProviders() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + err := h.setupAuthProviders(ctx) if err != nil { return nil, errors.WithMessage(err, "failed to setup authentication providers") } @@ -52,46 +40,45 @@ return h, nil } -func (h *Handler) setupAuthProviders() error { +func (h *Handler) setupAuthProviders(ctx context.Context) error { extUrl, err := url.Parse(h.config.Core.ExternalUrl) if err != nil { return errors.WithMessage(err, "failed to parse external url") } - for _, provider := range h.config.Auth.OpenIDConnect { - if _, exists := h.authProviderNames[provider.ProviderName]; exists { - return errors.Errorf("auth provider with name %s is already registerd", provider.ProviderName) - } - h.authProviderNames[provider.ProviderName] = AuthProviderTypeOpenIDConnect + for i := range h.config.Auth.OpenIDConnect { + providerCfg := &h.config.Auth.OpenIDConnect[i] + providerId := strings.ToLower(providerCfg.ProviderName) - var err error - h.oidcProviders[provider.ProviderName], err = oidc.NewProvider(context.Background(), provider.BaseUrl) - if err != nil { - return errors.WithMessagef(err, "failed to setup oidc provider %s", provider.ProviderName) + if _, exists := h.oauthAuthenticators[providerId]; exists { + return errors.Errorf("auth provider with name %s is already registerd", providerId) } - h.oidcVerifiers[provider.ProviderName] = h.oidcProviders[provider.ProviderName].Verifier(&oidc.Config{ - ClientID: provider.ClientID, - }) redirectUrl := *extUrl - redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", provider.ProviderName, "/callback") - scopes := []string{oidc.ScopeOpenID} - scopes = append(scopes, provider.Scopes...) - h.oauthConfigs[provider.ProviderName] = &oauth2.Config{ - ClientID: provider.ClientID, - ClientSecret: provider.ClientSecret, - Endpoint: h.oidcProviders[provider.ProviderName].Endpoint(), - RedirectURL: redirectUrl.String(), - Scopes: scopes, - } - } - for _, provider := range h.config.Auth.OAuth { - if _, exists := h.authProviderNames[provider.ProviderName]; exists { - return errors.Errorf("auth provider with name %s is already registerd", provider.ProviderName) - } - h.authProviderNames[provider.ProviderName] = AuthProviderTypeOAuth + redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback") - // TODO + authenticator, err := common.NewOidcAuthenticator(ctx, redirectUrl.String(), providerCfg) + if err != nil { + return errors.WithMessagef(err, "failed to setup oidc authentication provider %s", providerCfg.ProviderName) + } + h.oauthAuthenticators[providerId] = authenticator + } + for i := range h.config.Auth.OAuth { + providerCfg := &h.config.Auth.OAuth[i] + providerId := strings.ToLower(providerCfg.ProviderName) + + if _, exists := h.oauthAuthenticators[providerId]; exists { + return errors.Errorf("auth provider with name %s is already registerd", providerId) + } + + redirectUrl := *extUrl + redirectUrl.Path = path.Join(redirectUrl.Path, "/auth/login/", providerId, "/callback") + + authenticator, err := common.NewPlainOauthAuthenticator(ctx, redirectUrl.String(), providerCfg) + if err != nil { + return errors.WithMessagef(err, "failed to setup oauth authentication provider %s", providerId) + } + h.oauthAuthenticators[providerId] = authenticator } return nil @@ -180,3 +167,23 @@ return flashData } + +func UpdateSessionData(c *gin.Context, data common.SessionData) error { + session := sessions.Default(c) + session.Set(SessionIdentifier, data) + if err := session.Save(); err != nil { + logrus.Errorf("failed to store session: %v", err) + return errors.Wrap(err, "failed to store session") + } + return nil +} + +func DestroySessionData(c *gin.Context) error { + session := sessions.Default(c) + session.Delete(SessionIdentifier) + if err := session.Save(); err != nil { + logrus.Errorf("failed to destroy session: %v", err) + return errors.Wrap(err, "failed to destroy session") + } + return nil +} diff --git a/cmd/wg-portal/ui/pages_core.go b/cmd/wg-portal/ui/pages_core.go index 6155294..4c2da1c 100644 --- a/cmd/wg-portal/ui/pages_core.go +++ b/cmd/wg-portal/ui/pages_core.go @@ -3,18 +3,19 @@ import ( "crypto/rand" "encoding/base64" + "fmt" "html/template" "io" "net/http" "strings" "time" - "github.com/h44z/wg-portal/internal/persistence" - "github.com/coreos/go-oidc/v3/oidc" - "github.com/gin-gonic/gin" + "github.com/h44z/wg-portal/cmd/wg-portal/common" "github.com/h44z/wg-portal/internal" + "github.com/h44z/wg-portal/internal/persistence" + "github.com/pkg/errors" csrf "github.com/utrack/gin-csrf" ) @@ -104,30 +105,31 @@ c.Redirect(http.StatusSeeOther, "/") // already logged in } - deepLink := c.DefaultQuery("dl", "") - authError := c.DefaultQuery("err", "") - errMsg := "Unknown error occurred, try again!" - switch authError { - case "missingdata": - errMsg = "Invalid login data retrieved, please fill out all fields and try again!" - case "authfail": - errMsg = "Authentication failed!" - case "loginreq": - errMsg = "Login required!" + username := strings.ToLower(c.PostForm("username")) + password := c.PostForm("password") + deepLink := c.PostForm("_dl") + + // Validate form input + if strings.Trim(username, " ") == "" || strings.Trim(password, " ") == "" { + c.Redirect(http.StatusSeeOther, "/auth/login?err=missingdata") + return } - c.HTML(http.StatusOK, "login.html", gin.H{ + // TODO: implement db authentication + /*c.HTML(http.StatusOK, "login.html", gin.H{ "HasError": authError != "", "Message": errMsg, "DeepLink": deepLink, "Static": h.getStaticData(), "Csrf": csrf.GetToken(c), - }) + })*/ + + c.Redirect(http.StatusSeeOther, deepLink) } func (h *Handler) GetLoginOauth(c *gin.Context) { - provider := c.Param("provider") - if _, ok := h.authProviderNames[provider]; !ok { + providerId := c.Param("provider") + if _, ok := h.oauthAuthenticators[providerId]; !ok { c.Redirect(http.StatusSeeOther, "/auth/login?err=invalidprovider") return } @@ -145,11 +147,13 @@ } currentSession.OauthState = state - switch h.authProviderNames[provider] { - case AuthProviderTypeOAuth: - c.Redirect(http.StatusFound, h.oauthConfigs[provider].AuthCodeURL(state)) - return - case AuthProviderTypeOpenIDConnect: + authenticator := h.oauthAuthenticators[providerId] + + var authCodeUrl string + switch authenticator.GetType() { + case common.AuthenticatorTypeOAuth: + authCodeUrl = authenticator.AuthCodeURL(state) + case common.AuthenticatorTypeOidc: nonce, err := randString(16) if err != nil { c.Redirect(http.StatusSeeOther, "/auth/login?err=randsrcunavailable") @@ -157,14 +161,22 @@ } currentSession.OidcNonce = nonce - c.Redirect(http.StatusFound, h.oauthConfigs[provider].AuthCodeURL(state, oidc.Nonce(nonce))) + authCodeUrl = authenticator.AuthCodeURL(state, oidc.Nonce(nonce)) + } + + err = UpdateSessionData(c, currentSession) + if err != nil { + c.Redirect(http.StatusSeeOther, "/auth/login?err=sessionerror") return } + + c.Redirect(http.StatusFound, authCodeUrl) + } func (h *Handler) GetLoginOauthCallback(c *gin.Context) { - provider := c.Param("provider") - if _, ok := h.authProviderNames[provider]; !ok { + providerId := c.Param("provider") + if _, ok := h.oauthAuthenticators[providerId]; !ok { c.Redirect(http.StatusSeeOther, "/auth/login?err=invalidprovider") return } @@ -177,45 +189,33 @@ return } - oauth2Token, err := h.oauthConfigs[provider].Exchange(ctx, c.Query("code")) + authenticator := h.oauthAuthenticators[providerId] + oauthCode := c.Query("code") + oauth2Token, err := authenticator.Exchange(ctx, oauthCode) if err != nil { c.Redirect(http.StatusSeeOther, "/auth/login?err=tokenexchange") return } - switch h.authProviderNames[provider] { - case AuthProviderTypeOAuth: - // TODO - case AuthProviderTypeOpenIDConnect: - rawIDToken, ok := oauth2Token.Extra("id_token").(string) - if !ok { - c.Redirect(http.StatusSeeOther, "/auth/login?err=missingidtoken") - return - } - idToken, err := h.oidcVerifiers[provider].Verify(ctx, rawIDToken) - if err != nil { - c.Redirect(http.StatusSeeOther, "/auth/login?err=idtokeninvalid") - return - } - if idToken.Nonce != currentSession.OidcNonce { - c.Redirect(http.StatusSeeOther, "/auth/login?err=idtokennonce") - return - } - - // TODO: check if user exists in db, if not, maybe create? (if registration is allowed) - - currentSession.LoggedIn = true - currentSession.UserIdentifier = persistence.UserIdentifier(idToken.Subject) - - var extraFields map[string]interface{} - if err = idToken.Claims(&extraFields); err != nil { - c.Redirect(http.StatusSeeOther, "/auth/login?err=claimsparsing") - return - } - - // TODO: use FieldMap to get extra fields - //currentSession.Email = extraFields[mappedName] + rawUserInfo, err := authenticator.GetUserInfo(c.Request.Context(), oauth2Token, currentSession.OidcNonce) + if err != nil { + c.Redirect(http.StatusSeeOther, "/auth/login?err=userinfofetch") + return } + + userInfo, err := authenticator.ParseUserInfo(rawUserInfo) + + fmt.Println(userInfo) // TODO: implement login/registration process +} + +func (h *Handler) passwordAuthentication(username, password string) (*persistence.User, error) { + err := h.backend.PlaintextAuthentication(persistence.UserIdentifier(username), password) + if err != nil { + return nil, errors.WithMessage(err, "failed to authenticate") + } + + // TODO + return nil, nil } func randString(nByte int) (string, error) {