+
diff --git a/.gitignore b/.gitignore index cc611ee..d6cb379 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,10 @@ 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 *.exe \ No newline at end of file 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..45a008b 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" @@ -42,7 +44,7 @@ import ( const ( /* Build Constants */ SYSTEM_NAME = "Zoraxy" - SYSTEM_VERSION = "3.2.2" + SYSTEM_VERSION = "3.2.3" DEVELOPMENT_BUILD = false /* System Constants */ @@ -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..cfaafa2 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 } + case 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..abb4e79 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 */ 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..e7b4f4f 100644 --- a/src/web/components/httprp.html +++ b/src/web/components/httprp.html @@ -9,7 +9,7 @@ } .subdEntry td:not(.ignoremw){ - min-width: 200px; + min-width: 100px; } .httpProxyListTools{ @@ -20,10 +20,123 @@ cursor: pointer; } + th.no-sort{ + cursor: default !important; + } + .tag-select:hover{ text-decoration: underline; opacity: 0.8; } + + .inlineEditActionBtn{ + border: 0px solid transparent !important; + box-shadow: none !important; + background-color: transparent !important; + } + + body.darkTheme .ui.basic.small.icon.circular.button.inlineEditActionBtn{ + border: 0px solid transparent !important; + } + + /* Custom, non overlaying modal for proxy rule editing */ + #httprpEditModal{ + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 68vw; + height: 75vh; + background-color: var(--theme_bg_primary); + padding: 1.4em; + border-radius: .6em; + box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2), 0px 8px 16px rgba(0, 0, 0, 0.2); + z-index: 9; + max-width: 840px; + } + + #httprpEditModal .rpconfig_content{ + height: 100%; + } + + #httprpEditModal .editor_side_wrapper{ + position: absolute; + top: 0; + right: 0; + height:100%; + width: 100%; + background-color: var(--theme_bg_primary); + } + + #httprpEditModal .wrapper_frame{ + width: 100%; + border: 1px solid var(--divider_color); + border-radius: 0.5em; + height: calc(100%); + } + + #httprpEditModal .editor_side_wrapper .wrapper_frame{ + height: calc(100% - 30px); + } + + #httprpEditModal .editor_back_button { + border: none; + background-color: transparent; + box-shadow: none; + color: var(--text_color); + font-size: 2.2em; + cursor: pointer; + padding-top: 20px; + } + + #httprpEditModal .editor_back_button:hover{ + opacity: 0.5; + } + + @media screen and (max-width: 1024px) { + #httprpEditModal { + width: 85vw; + } + } + + @media screen and (max-width: 768px) { + #httprpEditModal { + height: 80vh; + border-radius: 0; + overflow-y: scroll; + } + + .httpProxyEditClosePC{ + display:none !important; + } + .httpProxyEditCloseMobile{ + display:block !important; + } + } + + @media screen and (min-width: 769px) { + .httpProxyEditClosePC{ + display:block !important; + } + .httpProxyEditCloseMobile{ + display:none !important; + } + } + + #httprpEditDarkenLayer { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 8; + backdrop-filter: blur(3px); + } + + #httprpEditModalWrapper { + display: none; /* Hidden by default */ + }