diff --git a/README.md b/README.md index 467eb33..d1d1605 100644 --- a/README.md +++ b/README.md @@ -43,30 +43,34 @@ For questions and support please use the [Gitter chat room](https://gitter.im/ta _Note for Caddy users_: Not all parameters are available in Caddy. See the table for details. With Caddy, the parameter names can be also be used with `_` in the names, e.g. `cookie_http_only`. -| Parameter | Type | Default | Caddy | Description | -|-------------------|-------------|--------------|-------|--------------------------------------------------------------------------------------| -| -cookie-domain | string | | X | The optional domain parameter for the cookie | -| -cookie-expiry | string | session | X | The expiry duration for the cookie, e.g. 2h or 3h30m | -| -cookie-http-only | boolean | true | X | Set the cookie with the http only flag | -| -cookie-name | string | "jwt_token" | X | The name of the jwt cookie | -| -github | value | | X | Oauth config in the form: client_id=..,client_secret=..[,scope=..,][redirect_uri=..] | -| -google | value | | X | Oauth config in the form: client_id=..,client_secret=..,scope=..[redirect_uri=..] | -| -bitbucket | value | | X | Oauth config in the form: client_id=..,client_secret=..,[scope=..][redirect_uri=..] | -| -host | string | "localhost" | - | The host to listen on | -| -htpasswd | value | | X | Htpasswd login backend opts: file=/path/to/pwdfile | -| -jwt-expiry | go duration | 24h | X | The expiry duration for the jwt token, e.g. 2h or 3h30m | -| -jwt-secret | string | "random key" | X | The secret to sign the jwt token | -| -log-level | string | "info" | - | The log level | -| -login-path | string | "/login" | X | The path of the login resource | -| -logout-url | string | | X | The url or path to redirect after logout | -| -osiam | value | | X | OSIAM login backend opts: endpoint=..,client_id=..,client_secret=.. | -| -port | string | "6789" | - | The port to listen on | -| -simple | value | | X | Simple login backend opts: user1=password,user2=password,.. | -| -success-url | string | "/" | X | The url to redirect after login | -| -template | string | | X | An alternative template for the login form | -| -text-logging | boolean | true | - | Log in text format instead of json | -| -jwt-refreshes | int | 0 | X | The maximum amount of jwt refreshes. | -| -grace-period | go duration | 5s | - | Duration to wait after SIGINT/SIGTERM for existing requests. No new requests are accepted. | +| Parameter | Type | Default | Caddy | Description | +|-----------------------------|-------------|--------------|-------|--------------------------------------------------------------------------------------------| +| -cookie-domain | string | | X | The optional domain parameter for the cookie | +| -cookie-expiry | string | session | X | The expiry duration for the cookie, e.g. 2h or 3h30m | +| -cookie-http-only | boolean | true | X | Set the cookie with the http only flag | +| -cookie-name | string | "jwt_token" | X | The name of the jwt cookie | +| -github | value | | X | Oauth config in the form: client_id=..,client_secret=..[,scope=..,][redirect_uri=..] | +| -google | value | | X | Oauth config in the form: client_id=..,client_secret=..,scope=..[redirect_uri=..] | +| -host | string | "localhost" | - | The host to listen on | +| -htpasswd | value | | X | Htpasswd login backend opts: file=/path/to/pwdfile | +| -jwt-expiry | go duration | 24h | X | The expiry duration for the jwt token, e.g. 2h or 3h30m | +| -jwt-secret | string | "random key" | X | The secret to sign the jwt token | +| -log-level | string | "info" | - | The log level | +| -login-path | string | "/login" | X | The path of the login resource | +| -logout-url | string | | X | The url or path to redirect after logout | +| -osiam | value | | X | OSIAM login backend opts: endpoint=..,client_id=..,client_secret=.. | +| -port | string | "6789" | - | The port to listen on | +| -simple | value | | X | Simple login backend opts: user1=password,user2=password,.. | +| -success-url | string | "/" | X | The url to redirect after login | +| -allow-redirects | boolean | true | X | Allow dynamic redirects | +| -redirect-query-parameter | string | "backTo" | X | The query parameter to find the dynamic redirect in | +| -prevent-external-redirects | boolean | true | X | Prevent dynamic redirects to external domains | +| -check-referer-on-redirects | boolean | true | X | Check the referer header to ensure it matches the host header on dynamic redirects | +| -whitelist-domains-file | string | "" | X | File containing whitelist of domains for dynamic redirects, one domain per line | +| -template | string | | X | An alternative template for the login form | +| -text-logging | boolean | true | - | Log in text format instead of json | +| -jwt-refreshes | int | 0 | X | The maximum amount of jwt refreshes. | +| -grace-period | go duration | 5s | - | Duration to wait after SIGINT/SIGTERM for existing requests. No new requests are accepted. | ### Environment Variables All of the above Config Options can also be applied as environment variable, where the name is written in the way: `LOGINSRV_OPTION_NAME`. diff --git a/caddy/README.md b/caddy/README.md index 7353da3..ef1c9c4 100644 --- a/caddy/README.md +++ b/caddy/README.md @@ -50,3 +50,24 @@ login { simple bob=secret,alice=secret } ``` + +### Example caddyfile with dynamic redirects +``` +127.0.0.1 + +root {$PWD} +browse + +jwt { + path / + except /favicon.ico + redirect /login?backTo={rewrite_uri} + allow sub bob + allow sub alice +} + +login { + simple bob=secret,alice=secret + check_referer_on_redirects false +} +``` diff --git a/caddy/setup_test.go b/caddy/setup_test.go index a8b7ff5..9a894be 100644 --- a/caddy/setup_test.go +++ b/caddy/setup_test.go @@ -2,15 +2,16 @@ package caddy import ( "fmt" + "io/ioutil" + "os" + "path/filepath" + "testing" + "time" + "github.com/mholt/caddy" "github.com/mholt/caddy/caddyhttp/httpserver" . "github.com/stretchr/testify/assert" "github.com/tarent/loginsrv/login" - "io/ioutil" - "path/filepath" - "os" - "testing" - "time" ) func TestSetup(t *testing.T) { @@ -28,12 +29,16 @@ func TestSetup(t *testing.T) { }`, shouldErr: false, config: login.Config{ - JwtSecret: "jwtsecret", - JwtExpiry: 24 * time.Hour, - SuccessURL: "/", - LoginPath: "/login", - CookieName: "jwt_token", - CookieHTTPOnly: true, + JwtSecret: "jwtsecret", + JwtExpiry: 24 * time.Hour, + SuccessURL: "/", + AllowRedirects: true, + RedirectQueryParameter: "backTo", + PreventExternalRedirects: true, + CheckRefererOnRedirects: true, + LoginPath: "/login", + CookieName: "jwt_token", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -47,6 +52,9 @@ func TestSetup(t *testing.T) { success_url successurl jwt_expiry 42h login_path /foo/bar + allow-redirects true + prevent-external-redirects true + check-referer-on-redirects true cookie_name cookiename cookie_http_only false cookie_domain example.com @@ -56,14 +64,18 @@ func TestSetup(t *testing.T) { }`, shouldErr: false, config: login.Config{ - JwtSecret: "jwtsecret", - JwtExpiry: 42 * time.Hour, - SuccessURL: "successurl", - LoginPath: "/foo/bar", - CookieName: "cookiename", - CookieDomain: "example.com", - CookieExpiry: 23*time.Hour + 23*time.Minute, - CookieHTTPOnly: false, + JwtSecret: "jwtsecret", + JwtExpiry: 42 * time.Hour, + SuccessURL: "successurl", + AllowRedirects: true, + RedirectQueryParameter: "backTo", + PreventExternalRedirects: true, + CheckRefererOnRedirects: true, + LoginPath: "/foo/bar", + CookieName: "cookiename", + CookieDomain: "example.com", + CookieExpiry: 23*time.Hour + 23*time.Minute, + CookieHTTPOnly: false, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -87,12 +99,16 @@ func TestSetup(t *testing.T) { }`, shouldErr: false, config: login.Config{ - JwtSecret: "jwtsecret", - JwtExpiry: 24 * time.Hour, - SuccessURL: "/", - LoginPath: "/context/login", - CookieName: "cookiename", - CookieHTTPOnly: true, + JwtSecret: "jwtsecret", + JwtExpiry: 24 * time.Hour, + SuccessURL: "/", + AllowRedirects: true, + RedirectQueryParameter: "backTo", + PreventExternalRedirects: true, + CheckRefererOnRedirects: true, + LoginPath: "/context/login", + CookieName: "cookiename", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -111,12 +127,16 @@ func TestSetup(t *testing.T) { }`, shouldErr: false, config: login.Config{ - JwtSecret: "jwtsecret", - JwtExpiry: 24 * time.Hour, - SuccessURL: "/", - LoginPath: "/login", - CookieName: "cookiename", - CookieHTTPOnly: true, + JwtSecret: "jwtsecret", + JwtExpiry: 24 * time.Hour, + SuccessURL: "/", + AllowRedirects: true, + RedirectQueryParameter: "backTo", + PreventExternalRedirects: true, + CheckRefererOnRedirects: true, + LoginPath: "/login", + CookieName: "cookiename", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -133,12 +153,16 @@ func TestSetup(t *testing.T) { }`, shouldErr: false, config: login.Config{ - JwtSecret: "jwtsecret", - JwtExpiry: 24 * time.Hour, - SuccessURL: "/", - LoginPath: "/login", - CookieName: "jwt_token", - CookieHTTPOnly: true, + JwtSecret: "jwtsecret", + JwtExpiry: 24 * time.Hour, + SuccessURL: "/", + AllowRedirects: true, + RedirectQueryParameter: "backTo", + PreventExternalRedirects: true, + CheckRefererOnRedirects: true, + LoginPath: "/login", + CookieName: "jwt_token", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -178,7 +202,7 @@ func TestSetup_RelativeTemplateFile(t *testing.T) { caddyfile := "loginsrv {\n template myTemplate.tpl\n simple bob=secret\n}" root, _ := ioutil.TempDir("", "") expectedPath := filepath.FromSlash(root + "/myTemplate.tpl") - + c := caddy.NewTestController("http", caddyfile) c.Key = "RelativeTemplateFileTest" config := httpserver.GetConfig(c) diff --git a/login/config.go b/login/config.go index 6732faf..ee14275 100644 --- a/login/config.go +++ b/login/config.go @@ -31,8 +31,10 @@ func DefaultConfig() *Config { JwtRefreshes: 0, SuccessURL: "/", AllowRedirects: true, + RedirectQueryParameter: "backTo", PreventExternalRedirects: true, CheckRefererOnRedirects: true, + WhitelistDomainsFile: "", LogoutURL: "", LoginPath: "/login", CookieName: "jwt_token", @@ -56,8 +58,10 @@ type Config struct { JwtRefreshes int SuccessURL string AllowRedirects bool + RedirectQueryParameter string PreventExternalRedirects bool CheckRefererOnRedirects bool + WhitelistDomainsFile string LogoutURL string Template string LoginPath string @@ -111,8 +115,10 @@ func (c *Config) ConfigureFlagSet(f *flag.FlagSet) { f.StringVar(&c.CookieDomain, "cookie-domain", c.CookieDomain, "The optional domain parameter for the cookie") f.StringVar(&c.SuccessURL, "success-url", c.SuccessURL, "The url to redirect after login") f.BoolVar(&c.AllowRedirects, "allow-redirects", c.AllowRedirects, "Allow dynamic redirects by parameter") + f.StringVar(&c.RedirectQueryParameter, "redirect-query-parameter", c.RedirectQueryParameter, "Allow dynamic redirects by parameter") f.BoolVar(&c.PreventExternalRedirects, "prevent-external-redirects", c.PreventExternalRedirects, "Prevent dynamic redirects from redirecting to an external domain") f.BoolVar(&c.CheckRefererOnRedirects, "check-referer-on-redirects", c.CheckRefererOnRedirects, "When redirecting check that the referer is the same domain") + f.StringVar(&c.WhitelistDomainsFile, "whitelist-domains-file", c.WhitelistDomainsFile, "the file containing a list of domains that redirects are allowed to, one domain per line") f.StringVar(&c.LogoutURL, "logout-url", c.LogoutURL, "The url or path to redirect after logout") f.StringVar(&c.Template, "template", c.Template, "An alternative template for the login form") diff --git a/login/config_test.go b/login/config_test.go index e93817c..95dc59d 100644 --- a/login/config_test.go +++ b/login/config_test.go @@ -2,10 +2,11 @@ package login import ( "flag" - . "github.com/stretchr/testify/assert" "os" "testing" "time" + + . "github.com/stretchr/testify/assert" ) func TestConfig_ReadConfigDefaults(t *testing.T) { @@ -28,6 +29,11 @@ func TestConfig_ReadConfig(t *testing.T) { "--jwt-secret=jwtsecret", "--jwt-expiry=42h42m", "--success-url=successurl", + "--allow-redirects=true", + "--redirect-query-parameter=backTo", + "--prevent-external-redirects=true", + "--check-referer-on-redirects=true", + "--whitelist-domains-file=File", "--logout-url=logouturl", "--template=template", "--login-path=loginpath", @@ -42,20 +48,25 @@ func TestConfig_ReadConfig(t *testing.T) { } expected := &Config{ - Host: "host", - Port: "port", - LogLevel: "loglevel", - TextLogging: true, - JwtSecret: "jwtsecret", - JwtExpiry: 42*time.Hour + 42*time.Minute, - SuccessURL: "successurl", - LogoutURL: "logouturl", - Template: "template", - LoginPath: "loginpath", - CookieName: "cookiename", - CookieExpiry: 23 * time.Minute, - CookieDomain: "*.example.com", - CookieHTTPOnly: false, + Host: "host", + Port: "port", + LogLevel: "loglevel", + TextLogging: true, + JwtSecret: "jwtsecret", + JwtExpiry: 42*time.Hour + 42*time.Minute, + SuccessURL: "successurl", + AllowRedirects: true, + RedirectQueryParameter: "backTo", + PreventExternalRedirects: true, + CheckRefererOnRedirects: true, + WhitelistDomainsFile: "File", + LogoutURL: "logouturl", + Template: "template", + LoginPath: "loginpath", + CookieName: "cookiename", + CookieExpiry: 23 * time.Minute, + CookieDomain: "*.example.com", + CookieHTTPOnly: false, Backends: Options{ "simple": map[string]string{}, "foo": map[string]string{}, @@ -82,6 +93,11 @@ func TestConfig_ReadConfigFromEnv(t *testing.T) { NoError(t, os.Setenv("LOGINSRV_JWT_SECRET", "jwtsecret")) NoError(t, os.Setenv("LOGINSRV_JWT_EXPIRY", "42h42m")) NoError(t, os.Setenv("LOGINSRV_SUCCESS_URL", "successurl")) + NoError(t, os.Setenv("LOGINSRV_ALLOW_REDIRECTS", "true")) + NoError(t, os.Setenv("LOGINSRV_REDIRECT_QUERY_PARAMETER", "backTo")) + NoError(t, os.Setenv("LOGINSRV_PREVENT_EXTERNAL_REDIRECTS", "true")) + NoError(t, os.Setenv("LOGINSRV_CHECK_REFERER_ON_REDIRECTS", "true")) + NoError(t, os.Setenv("LOGINSRV_WHITELIST_DOMAINS_FILE", "File")) NoError(t, os.Setenv("LOGINSRV_LOGOUT_URL", "logouturl")) NoError(t, os.Setenv("LOGINSRV_TEMPLATE", "template")) NoError(t, os.Setenv("LOGINSRV_LOGIN_PATH", "loginpath")) @@ -94,20 +110,25 @@ func TestConfig_ReadConfigFromEnv(t *testing.T) { NoError(t, os.Setenv("LOGINSRV_GRACE_PERIOD", "4s")) expected := &Config{ - Host: "host", - Port: "port", - LogLevel: "loglevel", - TextLogging: true, - JwtSecret: "jwtsecret", - JwtExpiry: 42*time.Hour + 42*time.Minute, - SuccessURL: "successurl", - LogoutURL: "logouturl", - Template: "template", - LoginPath: "loginpath", - CookieName: "cookiename", - CookieExpiry: 23 * time.Minute, - CookieDomain: "*.example.com", - CookieHTTPOnly: false, + Host: "host", + Port: "port", + LogLevel: "loglevel", + TextLogging: true, + JwtSecret: "jwtsecret", + JwtExpiry: 42*time.Hour + 42*time.Minute, + SuccessURL: "successurl", + AllowRedirects: true, + RedirectQueryParameter: "backTo", + PreventExternalRedirects: true, + CheckRefererOnRedirects: true, + WhitelistDomainsFile: "File", + LogoutURL: "logouturl", + Template: "template", + LoginPath: "loginpath", + CookieName: "cookiename", + CookieExpiry: 23 * time.Minute, + CookieDomain: "*.example.com", + CookieHTTPOnly: false, Backends: Options{ "simple": map[string]string{ "foo": "bar", diff --git a/login/handler.go b/login/handler.go index 1faf78e..36530a0 100644 --- a/login/handler.go +++ b/login/handler.go @@ -69,39 +69,28 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if (h.shouldRedirect(r)) && (r.Method != "POST") { + queries, _ := url.ParseQuery(r.URL.RawQuery) + if queries.Get(h.config.RedirectQueryParameter) != "" { + cookie := http.Cookie{ + Name: h.config.RedirectQueryParameter, + Value: queries.Get(h.config.RedirectQueryParameter), + } + spew.Dump(cookie) + http.SetCookie(w, &cookie) + } + } + _, err := h.oauth.GetConfigFromRequest(r) if err == nil { h.handleOauth(w, r) return } - if h.shouldSetCookie(r) { - queries, _ := url.ParseQuery(r.URL.RawQuery) - cookie := http.Cookie{Name: "redirect_url", Value: queries.Get("backTo")} - http.SetCookie(w, &cookie) - } h.handleLogin(w, r) return } -func (h *Handler) shouldSetCookie(r *http.Request) bool { - if h.config.AllowRedirects { - if h.config.CheckRefererOnRedirects { - referer, _ := url.Parse(r.Header.Get("Referer")) - if referer.Host != r.Host { - logging.Application(r.Header).Warnf( - "Referer domain: '%s' does not match current domain '%s'", - referer.Host, - r.Host, - ) - return false - } - } - return true - } - return false -} - func (h *Handler) handleOauth(w http.ResponseWriter, r *http.Request) { startedFlow, authenticated, userInfo, err := h.oauth.Handle(w, r) @@ -143,7 +132,7 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { r.ParseForm() if r.Method == "DELETE" || r.FormValue("logout") == "true" { - h.deleteToken(w) + h.deleteCookie(w, h.config.CookieName) if h.config.LogoutURL != "" { w.Header().Set("Location", h.config.LogoutURL) w.WriteHeader(303) @@ -218,9 +207,9 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request, userInfo } } -func (h *Handler) deleteToken(w http.ResponseWriter) { +func (h *Handler) deleteCookie(w http.ResponseWriter, cookieName string) { cookie := &http.Cookie{ - Name: h.config.CookieName, + Name: cookieName, Value: "delete", HttpOnly: true, Expires: time.Unix(0, 0), @@ -257,10 +246,11 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u http.SetCookie(w, cookie) - //redirectURL := h.config.SuccessURL - //fmt.Printf("redirectURL is: %s\n", h.redirectURL(r)) - - w.Header().Set("Location", h.redirectURL(r)) + w.Header().Set("Location", h.redirectURL(r, w)) + _, err := r.Cookie(h.config.RedirectQueryParameter) + if err == nil { + h.deleteCookie(w, h.config.RedirectQueryParameter) + } w.WriteHeader(303) return } @@ -270,63 +260,6 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u fmt.Fprintf(w, "%s", token) } -func (h *Handler) redirectURL(r *http.Request) string { - origin, _ := url.Parse(r.Header.Get("Origin")) - originScheme := origin.Scheme - originHost := origin.Host - redirectURL := fmt.Sprintf("%s://%s%s", originScheme, originHost, h.config.SuccessURL) - if h.config.AllowRedirects { - cookie, _ := r.Cookie("redirect_url") - parsedURL, err := url.Parse(cookie.Value) - if err != nil { - spew.Dump("oops can't parse that url") - } - scheme := parsedURL.Scheme - host := parsedURL.Host - path := parsedURL.Path - //spew.Dump(scheme) - //spew.Dump(host) - //spew.Dump(path) - - if scheme == "" { - scheme = originScheme - } - - if host == "" { - host = originHost - } else { - if h.config.PreventExternalRedirects { - if host != originHost { - logging.Application(r.Header).Warnf( - "Redirect attempt to '%s' but -prevent-external-redirects is set to true", - host, - ) - return redirectURL - } else { - host = originHost - } - } else { - //TODO read domains from file - domains := "2google.com" - if host != domains { - logging.Application(r.Header).Warnf( - "Redirect attempt to '%s' but it is not in domain whitelist", - host, - ) - return redirectURL - } - } - } - - if path == "" { - path = h.config.SuccessURL - } - - redirectURL = fmt.Sprintf("%s://%s%s", scheme, host, path) - } - return redirectURL -} - func (h *Handler) createToken(userInfo jwt.Claims) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS512, userInfo) return token.SignedString([]byte(h.config.JwtSecret)) diff --git a/login/handler_test.go b/login/handler_test.go index 12032ff..2287a08 100644 --- a/login/handler_test.go +++ b/login/handler_test.go @@ -3,22 +3,27 @@ package login import ( "errors" "fmt" - "github.com/dgrijalva/jwt-go" - . "github.com/stretchr/testify/assert" - "github.com/tarent/loginsrv/model" - "github.com/tarent/loginsrv/oauth2" + "io/ioutil" "net/http" "net/http/httptest" + "os" "strconv" "strings" "testing" "time" + + "github.com/dgrijalva/jwt-go" + . "github.com/stretchr/testify/assert" + "github.com/tarent/loginsrv/model" + "github.com/tarent/loginsrv/oauth2" ) const TypeJSON = "Content-Type: application/json" const TypeForm = "Content-Type: application/x-www-form-urlencoded" const AcceptHTML = "Accept: text/html" const AcceptJwt = "Accept: application/jwt" +const Host = "Host: example.com" +const BadReferer = "Referer: http://evildomain.com" func testConfig() *Config { testConfig := DefaultConfig() @@ -238,6 +243,110 @@ func TestHandler_LoginWeb(t *testing.T) { Equal(t, recorder.Header().Get("Set-Cookie"), "") } +func TestHandler_Redirect(t *testing.T) { + //by default set redirect_cookie + recorder := call(req("GET", "/context/login?backTo=/website", "", TypeForm, AcceptHTML)) + setCookieList := readSetCookies(recorder.Header()) + Equal(t, 1, len(setCookieList)) + cookie := setCookieList[0] + Equal(t, "backTo", cookie.Name) + Equal(t, "/website", cookie.Value) + + //by default allowed redirects + recorder = call(req("POST", "/context/login?backTo=/website", "username=bob&password=secret", TypeForm, AcceptHTML)) + Equal(t, 303, recorder.Code) + Equal(t, "/website", recorder.Header().Get("Location")) + + //redirect to SuccessURL if AllowRedirects is false + cfg := DefaultConfig() + cfg.AllowRedirects = false + h := &Handler{ + backends: []Backend{ + NewSimpleBackend(map[string]string{"bob": "secret"}), + }, + oauth: oauth2.NewManager(), + config: cfg, + } + recorder = httptest.NewRecorder() + h.ServeHTTP(recorder, req("POST", "/login?backTo=/website", "username=bob&password=secret", TypeForm, AcceptHTML)) + Equal(t, 303, recorder.Code) + Equal(t, "/", recorder.Header().Get("Location")) + + //by default don't set redirect cookie if Referer doesn't match origin + recorder = call(req("GET", "/context/login?backTo=/website", "", TypeForm, AcceptHTML, BadReferer)) + setCookieList = readSetCookies(recorder.Header()) + Equal(t, 0, len(setCookieList)) + + //don't set redirect cookie if referrer is malformed + recorder = call(req("GET", "/context/login?backTo=/website", "", TypeForm, AcceptHTML, "Referer: :notvalid")) + setCookieList = readSetCookies(recorder.Header()) + Equal(t, 0, len(setCookieList)) + + //set redirect cookie with mismatch referer if CheckRefererOnRedirects is false + cfg = DefaultConfig() + cfg.CheckRefererOnRedirects = false + h = &Handler{ + backends: []Backend{ + NewSimpleBackend(map[string]string{"bob": "secret"}), + }, + oauth: oauth2.NewManager(), + config: cfg, + } + recorder = httptest.NewRecorder() + h.ServeHTTP(recorder, req("GET", "/login?backTo=/website", "", TypeForm, AcceptHTML, BadReferer)) + setCookieList = readSetCookies(recorder.Header()) + Equal(t, 1, len(setCookieList)) + cookie = setCookieList[0] + Equal(t, "backTo", cookie.Name) + Equal(t, "/website", cookie.Value) + + //by default prevent redirect to external site + recorder = call(req("POST", "/context/login?backTo=//evildomain.com/phishing.html", "username=bob&password=secret", TypeForm, AcceptHTML)) + Equal(t, 303, recorder.Code) + Equal(t, "/", recorder.Header().Get("Location")) + + //by default if the parsed path is empty redirect to SuccessURL + recorder = call(req("POST", "/context/login?backTo=https://evildomain.com", "username=bob&password=secret", TypeForm, AcceptHTML)) + Equal(t, 303, recorder.Code) + Equal(t, "/", recorder.Header().Get("Location")) + + //redirect to success url if domains whitelist file doesn't exist + cfg = DefaultConfig() + cfg.PreventExternalRedirects = false + cfg.WhitelistDomainsFile = "domains_whitelist.txt" + h = &Handler{ + backends: []Backend{ + NewSimpleBackend(map[string]string{"bob": "secret"}), + }, + oauth: oauth2.NewManager(), + config: cfg, + } + recorder = httptest.NewRecorder() + h.ServeHTTP(recorder, req("POST", "/login?backTo=https://gooddomain.com/website", "username=bob&password=secret", TypeForm, AcceptHTML, BadReferer)) + Equal(t, 303, recorder.Code) + Equal(t, "/", recorder.Header().Get("Location")) + + //setup domain whitelist file + d1 := []byte("gooddomain.com\n") + _ = ioutil.WriteFile("domains_whitelist.txt", d1, 0644) + + //allow redirect to domains on whitelist + recorder = httptest.NewRecorder() + h.ServeHTTP(recorder, req("POST", "/login?backTo=https://gooddomain.com/website", "username=bob&password=secret", TypeForm, AcceptHTML, BadReferer)) + Equal(t, 303, recorder.Code) + Equal(t, "https://gooddomain.com/website", recorder.Header().Get("Location")) + + //allow redirect to domains on whitelist + recorder = httptest.NewRecorder() + h.ServeHTTP(recorder, req("POST", "/login?backTo=https://evildomain.com/website", "username=bob&password=secret", TypeForm, AcceptHTML, BadReferer)) + Equal(t, 303, recorder.Code) + Equal(t, "/", recorder.Header().Get("Location")) + + //remove domains whitelist file + err := os.Remove("domains_whitelist.txt") + Equal(t, nil, err) +} + func TestHandler_Refresh(t *testing.T) { h := testHandler() input := model.UserInfo{Sub: "bob", Expiry: time.Now().Add(time.Second).Unix()} diff --git a/login/redirect.go b/login/redirect.go new file mode 100644 index 0000000..256c5ef --- /dev/null +++ b/login/redirect.go @@ -0,0 +1,119 @@ +package login + +import ( + "bufio" + "errors" + "fmt" + "net/http" + "net/url" + "os" + + "github.com/tarent/loginsrv/logging" +) + +func (h *Handler) shouldRedirect(r *http.Request) bool { + if h.config.AllowRedirects { + if h.config.CheckRefererOnRedirects { + referer, err := url.Parse(r.Header.Get("Referer")) + if err != nil { + logging.Application(r.Header).Warnf( + "couldn't parse redirect url %s", + err, + ) + return false + } + if referer.Host != r.Host { + logging.Application(r.Header).Warnf( + "Referer domain: '%s' does not match current domain '%s'", + referer.Host, + r.Host, + ) + return false + } + } + return true + } + return false +} + +func (h *Handler) redirectURL(r *http.Request, w http.ResponseWriter) string { + if h.config.AllowRedirects { + parsedURL, err := h.parseURL(r) + if err != nil { + logging.Application(r.Header).Warnf( + "error parsing redict URL: %s", + err, + ) + return h.config.SuccessURL + } + if h.config.PreventExternalRedirects { + if parsedURL.Path == "" { + return h.config.SuccessURL + } else { + if (parsedURL.Host != "") && (r.Host != parsedURL.Host) { + logging.Application(r.Header).Warnf( + "Attempted redirect to %s", + parsedURL.Host, + ) + return h.config.SuccessURL + } + return parsedURL.Path + } + } else { + if h.checkWhiteListDomains(r, parsedURL.Host) { + return fmt.Sprintf( + "%s://%s%s", + parsedURL.Scheme, + parsedURL.Host, + parsedURL.Path, + ) + } else { + return h.config.SuccessURL + } + } + } + return h.config.SuccessURL +} + +func (h *Handler) parseURL(r *http.Request) (*url.URL, error) { + cookie, err := r.Cookie(h.config.RedirectQueryParameter) + if err != nil { + //try reading parameter as it might be a POST request and so not have set the cookie yet + queries, err := url.ParseQuery(r.URL.RawQuery) + if err != nil { + return nil, err + } + if queries.Get(h.config.RedirectQueryParameter) != "" { + parsedURL, err := url.Parse(queries.Get(h.config.RedirectQueryParameter)) + return parsedURL, err + } else { + return nil, errors.New("no redirect") + } + } + parsedURL, err := url.Parse(cookie.Value) + return parsedURL, err +} + +func (h *Handler) checkWhiteListDomains(r *http.Request, host string) bool { + f, err := os.Open(h.config.WhitelistDomainsFile) + defer f.Close() + if err != nil { + logging.Application(r.Header).Warnf( + "can't open domains file '%s'", + h.config.WhitelistDomainsFile, + ) + return false + } + scanner := bufio.NewScanner(f) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + if host == scanner.Text() { + return true + } + } + logging.Application(r.Header).Warnf( + "Domain '%s' not in whitelist", + host, + ) + return false +}