diff --git a/.gitignore b/.gitignore index 5c9767d..fabfcb3 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,9 @@ src/log/ example/plugins/ztnc/ztnc.db example/plugins/ztnc/authtoken.secret example/plugins/ztnc/ztnc.db.lock +.idea +conf +log +tmp +sys.* +www/html/index.html diff --git a/src/api.go b/src/api.go index 090e5d8..eb99977 100644 --- a/src/api.go +++ b/src/api.go @@ -83,6 +83,7 @@ func RegisterTLSAPIs(authRouter *auth.RouterDef) { // Register the APIs for Authentication handlers like Forward Auth and OAUTH2 func RegisterAuthenticationHandlerAPIs(authRouter *auth.RouterDef) { authRouter.HandleFunc("/api/sso/forward-auth", forwardAuthRouter.HandleAPIOptions) + authRouter.HandleFunc("/api/sso/OAuth2", oauth2Router.HandleSetOAuth2Settings) } // Register the APIs for redirection rules management functions diff --git a/src/def.go b/src/def.go index 66995ff..7d1f616 100644 --- a/src/def.go +++ b/src/def.go @@ -13,6 +13,8 @@ import ( "net/http" "time" + "imuslab.com/zoraxy/mod/auth/sso/oauth2" + "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/acme" "imuslab.com/zoraxy/mod/auth" @@ -143,7 +145,8 @@ var ( pluginManager *plugins.Manager //Plugin manager for managing plugins //Authentication Provider - forwardAuthRouter *forward.AuthRouter // Forward Auth router for Authelia/Authentik/etc authentication + forwardAuthRouter *forward.AuthRouter // Forward Auth router for Authelia/Authentik/etc authentication + oauth2Router *oauth2.OAuth2Router //OAuth2Router router for OAuth2Router authentication //Helper modules EmailSender *email.Sender //Email sender that handle email sending diff --git a/src/go.mod b/src/go.mod index e722aa9..3b0d5e4 100644 --- a/src/go.mod +++ b/src/go.mod @@ -16,6 +16,7 @@ require ( github.com/grandcat/zeroconf v1.0.0 github.com/likexian/whois v1.15.1 github.com/microcosm-cc/bluemonday v1.0.26 + github.com/monperrus/crawler-user-agents v1.1.0 github.com/shirou/gopsutil/v4 v4.25.1 github.com/syndtr/goleveldb v1.0.0 golang.org/x/net v0.33.0 @@ -32,7 +33,6 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/golang/snappy v0.0.1 // indirect github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.128 // indirect - github.com/monperrus/crawler-user-agents v1.1.0 // indirect github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect github.com/peterhellberg/link v1.2.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect diff --git a/src/mod/auth/sso/deprecated/authentik/authentik.go b/src/mod/auth/sso/deprecated/authentik/authentik.go index 795b0b3..a4abbc6 100644 --- a/src/mod/auth/sso/deprecated/authentik/authentik.go +++ b/src/mod/auth/sso/deprecated/authentik/authentik.go @@ -56,7 +56,7 @@ func (ar *AuthentikRouter) HandleSetAuthentikURLAndHTTPS(w http.ResponseWriter, return } - useHTTPS, err := utils.PostBool(r, "useHTTPS") + useHTTPS, err := utils.PostBool(r, "authentikUseHttps") if err != nil { useHTTPS = false } diff --git a/src/mod/auth/sso/oauth2/oauth2.go b/src/mod/auth/sso/oauth2/oauth2.go new file mode 100644 index 0000000..449c44e --- /dev/null +++ b/src/mod/auth/sso/oauth2/oauth2.go @@ -0,0 +1,286 @@ +package oauth2 + +import ( + "context" + "encoding/json" + "errors" + "golang.org/x/oauth2" + "imuslab.com/zoraxy/mod/database" + "imuslab.com/zoraxy/mod/info/logger" + "imuslab.com/zoraxy/mod/utils" + "net/http" + "net/url" + "strings" +) + +type OAuth2RouterOptions struct { + OAuth2ServerURL string //The URL of the OAuth 2.0 server server + OAuth2TokenURL string //The URL of the OAuth 2.0 token server + OAuth2ClientId string //The client id for OAuth 2.0 Application + OAuth2ClientSecret string //The client secret for OAuth 2.0 Application + OAuth2WellKnownUrl string //The well-known url for OAuth 2.0 server + OAuth2UserInfoUrl string //The URL of the OAuth 2.0 user info endpoint + OAuth2Scopes string //The scopes for OAuth 2.0 Application + Logger *logger.Logger + Database *database.Database +} + +type OIDCDiscoveryDocument struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + ClaimsSupported []string `json:"claims_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` + Issuer string `json:"issuer"` + JwksURI string `json:"jwks_uri"` + ResponseTypesSupported []string `json:"response_types_supported"` + ScopesSupported []string `json:"scopes_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + TokenEndpoint string `json:"token_endpoint"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + UserinfoEndpoint string `json:"userinfo_endpoint"` +} + +type OAuth2Router struct { + options *OAuth2RouterOptions +} + +// NewOAuth2Router creates a new OAuth2Router object +func NewOAuth2Router(options *OAuth2RouterOptions) *OAuth2Router { + options.Database.NewTable("oauth2") + + //Read settings from database, if exists + options.Database.Read("oauth2", "oauth2WellKnownUrl", &options.OAuth2WellKnownUrl) + options.Database.Read("oauth2", "oauth2ServerUrl", &options.OAuth2ServerURL) + options.Database.Read("oauth2", "oauth2TokenUrl", &options.OAuth2TokenURL) + options.Database.Read("oauth2", "oauth2ClientId", &options.OAuth2ClientId) + options.Database.Read("oauth2", "oauth2ClientSecret", &options.OAuth2ClientSecret) + options.Database.Read("oauth2", "oauth2UserInfoUrl", &options.OAuth2UserInfoUrl) + options.Database.Read("oauth2", "oauth2Scopes", &options.OAuth2Scopes) + + return &OAuth2Router{ + options: options, + } +} + +// HandleSetOAuth2Settings is the internal handler for setting the OAuth URL and HTTPS +func (ar *OAuth2Router) HandleSetOAuth2Settings(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + //Return the current settings + js, _ := json.Marshal(map[string]interface{}{ + "oauth2WellKnownUrl": ar.options.OAuth2WellKnownUrl, + "oauth2ServerUrl": ar.options.OAuth2ServerURL, + "oauth2TokenUrl": ar.options.OAuth2TokenURL, + "oauth2UserInfoUrl": ar.options.OAuth2UserInfoUrl, + "oauth2Scopes": ar.options.OAuth2Scopes, + "oauth2ClientSecret": ar.options.OAuth2ClientSecret, + "oauth2ClientId": ar.options.OAuth2ClientId, + }) + + utils.SendJSONResponse(w, string(js)) + return + } else if r.Method == http.MethodPost { + //Update the settings + var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl string + oauth2WellKnownUrl, err := utils.PostPara(r, "oauth2WellKnownUrl") + if err != nil { + oauth2ServerUrl, err = utils.PostPara(r, "oauth2ServerUrl") + if err != nil { + utils.SendErrorResponse(w, "oauth2ServerUrl not found") + return + } + + oauth2TokenURL, err = utils.PostPara(r, "oauth2TokenUrl") + if err != nil { + utils.SendErrorResponse(w, "oauth2TokenUrl not found") + return + } + + oauth2Scopes, err = utils.PostPara(r, "oauth2Scopes") + if err != nil { + utils.SendErrorResponse(w, "oauth2Scopes not found") + return + } + + oauth2UserInfoUrl, err = utils.PostPara(r, "oauth2UserInfoUrl") + if err != nil { + utils.SendErrorResponse(w, "oauth2UserInfoUrl not found") + return + } + } + + oauth2ClientId, err := utils.PostPara(r, "oauth2ClientId") + if err != nil { + utils.SendErrorResponse(w, "oauth2ClientId not found") + return + } + + oauth2ClientSecret, err := utils.PostPara(r, "oauth2ClientSecret") + if err != nil { + utils.SendErrorResponse(w, "oauth2ClientSecret not found") + return + } + + //Write changes to runtime + ar.options.OAuth2WellKnownUrl = oauth2WellKnownUrl + ar.options.OAuth2ServerURL = oauth2ServerUrl + ar.options.OAuth2TokenURL = oauth2TokenURL + ar.options.OAuth2UserInfoUrl = oauth2UserInfoUrl + ar.options.OAuth2ClientId = oauth2ClientId + ar.options.OAuth2ClientSecret = oauth2ClientSecret + ar.options.OAuth2Scopes = oauth2Scopes + + //Write changes to database + ar.options.Database.Write("oauth2", "oauth2WellKnownUrl", oauth2WellKnownUrl) + ar.options.Database.Write("oauth2", "oauth2ServerUrl", oauth2ServerUrl) + ar.options.Database.Write("oauth2", "oauth2TokenUrl", oauth2TokenURL) + ar.options.Database.Write("oauth2", "oauth2UserInfoUrl", oauth2UserInfoUrl) + ar.options.Database.Write("oauth2", "oauth2ClientId", oauth2ClientId) + ar.options.Database.Write("oauth2", "oauth2ClientSecret", oauth2ClientSecret) + ar.options.Database.Write("oauth2", "oauth2Scopes", oauth2Scopes) + + utils.SendOK(w) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + +} + +func (ar *OAuth2Router) fetchOAuth2Configuration(config *oauth2.Config) (*oauth2.Config, error) { + req, err := http.NewRequest("GET", ar.options.OAuth2WellKnownUrl, nil) + if err != nil { + return nil, err + } + client := &http.Client{} + if resp, err := client.Do(req); err != nil { + return nil, err + } else { + defer resp.Body.Close() + + oidcDiscoveryDocument := OIDCDiscoveryDocument{} + if err := json.NewDecoder(resp.Body).Decode(&oidcDiscoveryDocument); err != nil { + return nil, err + } + + if len(config.Scopes) == 0 { + config.Scopes = oidcDiscoveryDocument.ScopesSupported + } + + if config.Endpoint.AuthURL == "" { + config.Endpoint.AuthURL = oidcDiscoveryDocument.AuthorizationEndpoint + } + + if config.Endpoint.TokenURL == "" { + config.Endpoint.TokenURL = oidcDiscoveryDocument.TokenEndpoint + } + + if ar.options.OAuth2UserInfoUrl == "" { + ar.options.OAuth2UserInfoUrl = oidcDiscoveryDocument.UserinfoEndpoint + } + + } + return config, nil +} + +func (ar *OAuth2Router) newOAuth2Conf(redirectUrl string) (*oauth2.Config, error) { + config := &oauth2.Config{ + ClientID: ar.options.OAuth2ClientId, + ClientSecret: ar.options.OAuth2ClientSecret, + RedirectURL: redirectUrl, + Endpoint: oauth2.Endpoint{ + AuthURL: ar.options.OAuth2ServerURL, + TokenURL: ar.options.OAuth2TokenURL, + }, + } + if ar.options.OAuth2Scopes != "" { + config.Scopes = strings.Split(ar.options.OAuth2Scopes, ",") + } + if ar.options.OAuth2WellKnownUrl != "" && (config.Endpoint.AuthURL == "" || config.Endpoint.TokenURL == "" || + ar.options.OAuth2UserInfoUrl == "") { + return ar.fetchOAuth2Configuration(config) + } + return config, nil +} + +// HandleOAuth2Auth is the internal handler for OAuth authentication +// Set useHTTPS to true if your OAuth server is using HTTPS +// Set OAuthURL to the URL of the OAuth server, e.g. OAuth.example.com +func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request) error { + const callbackPrefix = "/internal/oauth2" + const tokenCookie = "z-token" + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + reqUrl := scheme + "://" + r.Host + r.RequestURI + oauthConfig, err := ar.newOAuth2Conf(scheme + "://" + r.Host + callbackPrefix) + if err != nil { + ar.options.Logger.PrintAndLog("OAuth2Router", "Failed to fetch OIDC configuration:", err) + w.WriteHeader(500) + return errors.New("failed to fetch OIDC configuration") + } + + if oauthConfig.Endpoint.AuthURL == "" || oauthConfig.Endpoint.TokenURL == "" || ar.options.OAuth2UserInfoUrl == "" { + ar.options.Logger.PrintAndLog("OAuth2Router", "Invalid OAuth2 configuration", nil) + w.WriteHeader(500) + return errors.New("invalid OAuth2 configuration") + } + + code := r.URL.Query().Get("code") + state := r.URL.Query().Get("state") + if r.Method == http.MethodGet && strings.HasPrefix(r.RequestURI, callbackPrefix) && code != "" && state != "" { + ctx := context.Background() + token, err := oauthConfig.Exchange(ctx, code) + if err != nil { + ar.options.Logger.PrintAndLog("OAuth2", "Token exchange failed", err) + w.WriteHeader(401) + return errors.New("unauthorized") + } + + if !token.Valid() { + ar.options.Logger.PrintAndLog("OAuth2", "Invalid token", err) + w.WriteHeader(401) + return errors.New("unauthorized") + } + + cookie := http.Cookie{Name: tokenCookie, Value: token.AccessToken, Path: "/"} + if scheme == "https" { + cookie.Secure = true + cookie.SameSite = http.SameSiteLaxMode + } + w.Header().Add("Set-Cookie", cookie.String()) + http.Redirect(w, r, state, http.StatusTemporaryRedirect) + return errors.New("authorized") + } + unauthorized := false + cookie, err := r.Cookie(tokenCookie) + if err == nil { + if cookie.Value == "" { + unauthorized = true + } else { + ctx := context.Background() + client := oauthConfig.Client(ctx, &oauth2.Token{AccessToken: cookie.Value}) + req, err := client.Get(ar.options.OAuth2UserInfoUrl) + if err != nil { + ar.options.Logger.PrintAndLog("OAuth2", "Failed to get user info", err) + unauthorized = true + } + defer req.Body.Close() + if req.StatusCode != http.StatusOK { + ar.options.Logger.PrintAndLog("OAuth2", "Failed to get user info", err) + unauthorized = true + } + } + } else { + unauthorized = true + } + if unauthorized { + state := url.QueryEscape(reqUrl) + url := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) + http.Redirect(w, r, url, http.StatusFound) + + return errors.New("unauthorized") + } + return nil +} diff --git a/src/mod/dynamicproxy/authProviders.go b/src/mod/dynamicproxy/authProviders.go index 1397a45..295add6 100644 --- a/src/mod/dynamicproxy/authProviders.go +++ b/src/mod/dynamicproxy/authProviders.go @@ -46,6 +46,12 @@ func handleAuthProviderRouting(sep *ProxyEndpoint, w http.ResponseWriter, r *htt h.Parent.Option.Logger.LogHTTPRequest(r, "host-http", 401, requestHostname, "") return true } + } else if sep.AuthenticationProvider.AuthMethod == AuthMethodOAuth2 { + err := h.handleOAuth2Auth(w, r) + if err != nil { + h.Parent.Option.Logger.LogHTTPRequest(r, "host-http", 401, requestHostname, "") + return true + } } //No authentication provider, do not need to handle @@ -108,3 +114,7 @@ func handleBasicAuth(w http.ResponseWriter, r *http.Request, pe *ProxyEndpoint) func (h *ProxyHandler) handleForwardAuth(w http.ResponseWriter, r *http.Request) error { return h.Parent.Option.ForwardAuthRouter.HandleAuthProviderRouting(w, r) } + +func (h *ProxyHandler) handleOAuth2Auth(w http.ResponseWriter, r *http.Request) error { + return h.Parent.Option.OAuth2Router.HandleOAuth2Auth(w, r) +} diff --git a/src/mod/dynamicproxy/typedef.go b/src/mod/dynamicproxy/typedef.go index bf181f4..36eb39e 100644 --- a/src/mod/dynamicproxy/typedef.go +++ b/src/mod/dynamicproxy/typedef.go @@ -13,6 +13,8 @@ import ( "net/http" "sync" + "imuslab.com/zoraxy/mod/auth/sso/oauth2" + "imuslab.com/zoraxy/mod/access" "imuslab.com/zoraxy/mod/auth/sso/forward" "imuslab.com/zoraxy/mod/dynamicproxy/dpcore" @@ -64,6 +66,7 @@ type RouterOption struct { /* Authentication Providers */ ForwardAuthRouter *forward.AuthRouter + OAuth2Router *oauth2.OAuth2Router //OAuth2Router router for OAuth2Router authentication /* Utilities */ Logger *logger.Logger //Logger for reverse proxy requets diff --git a/src/mod/sshprox/embed.go b/src/mod/sshprox/embed.go index 4c5fe2d..b5ac80d 100644 --- a/src/mod/sshprox/embed.go +++ b/src/mod/sshprox/embed.go @@ -1,14 +1,14 @@ -//go:build (windows && amd64) || (linux && mipsle) || (linux && riscv64) || (freebsd && amd64) -// +build windows,amd64 linux,mipsle linux,riscv64 freebsd,amd64 +//go:build (windows && amd64) || (linux && mipsle) || (linux && riscv64) || (freebsd && amd64) || (darwin && arm64) +// +build windows,amd64 linux,mipsle linux,riscv64 freebsd,amd64 darwin,arm64 package sshprox import "embed" /* - Binary embedding +Binary embedding - Make sure when compile, gotty binary exists in static.gotty +Make sure when compile, gotty binary exists in static.gotty */ var ( //go:embed gotty/LICENSE diff --git a/src/reverseproxy.go b/src/reverseproxy.go index 8e60335..9585de8 100644 --- a/src/reverseproxy.go +++ b/src/reverseproxy.go @@ -116,6 +116,7 @@ func ReverseProxtInit() { WebDirectory: *path_webserver, AccessController: accessController, ForwardAuthRouter: forwardAuthRouter, + OAuth2Router: oauth2Router, LoadBalancer: loadBalancer, PluginManager: pluginManager, /* Utilities */ @@ -586,7 +587,7 @@ func ReverseProxyHandleEditEndpoint(w http.ResponseWriter, r *http.Request) { } else if authProviderType == 2 { newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodForward } else if authProviderType == 3 { - newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodOauth2 + newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodOAuth2 } else { newProxyEndpoint.AuthenticationProvider.AuthMethod = dynamicproxy.AuthMethodNone } diff --git a/src/start.go b/src/start.go index d847744..7f191ea 100644 --- a/src/start.go +++ b/src/start.go @@ -1,6 +1,7 @@ package main import ( + "imuslab.com/zoraxy/mod/auth/sso/oauth2" "log" "net/http" "os" @@ -147,6 +148,11 @@ func startupSequence() { Database: sysdb, }) + oauth2Router = oauth2.NewOAuth2Router(&oauth2.OAuth2RouterOptions{ + Logger: SystemWideLogger, + Database: sysdb, + }) + //Create a statistic collector statisticCollector, err = statistic.NewStatisticCollector(statistic.CollectorOption{ Database: sysdb, diff --git a/src/web/components/httprp.html b/src/web/components/httprp.html index 69a773b..484eb37 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -395,6 +395,12 @@ +
Configuration settings for OAuth 2.0 authentication provider.
+ +