loginsrv

Unnamed repository; edit this file 'description' to name the repository.
git clone git@jamesshield.xyz:repos/loginsrv.git
Log | Files | Refs | README | LICENSE

commit 0d99e0462cb72464572e003bd3bd26cf2615d1c4
parent fc82c689a6dcf178ae4786bf8cfcd3159da8bcc8
Author: Sebastian Mancke <s.mancke@tarent.de>
Date:   Fri, 16 Feb 2018 09:31:53 +0100

Merge pull request #64 from tarent/dynamic-redirects

Dynamic redirects
Diffstat:
MREADME.md | 81++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mcaddy/README.md | 22++++++++++++++++++++++
Mcaddy/demo/Caddyfile | 1+
Acaddy/demo/redirect_hosts.txt | 1+
Mcaddy/demo/webroot/index.html | 4+++-
Mcaddy/setup.go | 4++--
Mcaddy/setup_test.go | 108++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mlogin/config.go | 77+++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mlogin/config_test.go | 75++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mlogin/handler.go | 14+++++++++-----
Mlogin/handler_test.go | 9+++++----
Alogin/redirect.go | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alogin/redirect_test.go | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 491 insertions(+), 147 deletions(-)

diff --git 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 | +| -redirect | boolean | true | X | Allow dynamic overwriting of the the success by query parameter (default true) | +| -redirect-query-parameter | string | "backTo" | X | URL parameter for the redirect target (default "backTo") | +| -redirect-check-referer | boolean | true | X | Check the referer header to ensure it matches the host header on dynamic redirects | +| -redirect-host-file | string | "" | X | A file containing a list of domains that redirects are allowed to, one domain per line | +| -simple | value | | X | Simple login backend opts: user1=password,user2=password,.. | +| -success-url | string | "/" | X | The url to redirect after login | +| -prevent-external-redirects | boolean | true | X | Prevent dynamic redirects to external domains | +| -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`. @@ -106,14 +110,15 @@ Performs the login and returns the JWT. Depending on the content-type and parame #### Runtime Parameters -| Parameter-Type | Parameter | Description | | -| ------------------|--------------------------------------------------|-----------------------------------------------------------|----------| -| Http-Header | Accept: text/html | Set the JWT-Token as Cookie 'jwt_token'. | default | -| Http-Header | Accept: application/jwt | Returns the JWT-Token within the body. No Cookie is set. | | -| Http-Header | Content-Type: application/x-www-form-urlencoded | Expect the credentials as form encoded parameters. | default | -| Http-Header | Content-Type: application/json | Take the credentials from the provided json object. | | -| Post-Parameter | username | The username | | -| Post-Parameter | password | The password | | +| Parameter-Type | Parameter | Description | | +| ------------------|--------------------------------------------------|-------------------------------------------------------------------|--------------| +| Http-Header | Accept: text/html | Set the JWT-Token as Cookie 'jwt_token'. | default | +| Http-Header | Accept: application/jwt | Returns the JWT-Token within the body. No Cookie is set. | | +| Http-Header | Content-Type: application/x-www-form-urlencoded | Expect the credentials as form encoded parameters. | default | +| Http-Header | Content-Type: application/json | Take the credentials from the provided json object. | | +| Post-Parameter | username | The username | | +| Post-Parameter | password | The password | | +| Get or Post | backTo | Dynamic redirect target after login (see (Redirects)[#redirects]) | -success-url | #### Possible Return Codes @@ -173,6 +178,17 @@ Location: / Set-Cookie: jwt_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJib2IifQ.-51G5JQmpJleARHp8rIljBczPFanWT93d_N_7LQGUXU; HttpOnly ``` +### Redirects + +The api has support for a redirect query paramter, e.g. `?backTo=/dynamic/return/path`. For security reasons, the default behaviour is very restrictive: + +* Only local redirects (same host) are allowed. +* The `Referer` header is checked in the way, that the call to the login page has to come from the same page. + +These restrictions are there, to prevent you from unchecked redirect attacks, e.g. using your site for fishing or doing login attacks. +If you know, what you are doing, you can disable the referer check with `--redirect-check-referer=false` and provide a whitelist file +for allowed external domains with `--redirect-host-file=/some/domains.txt`. + ## The JWT Token Depending on the provider, the token may look as follows: ``` @@ -305,3 +321,4 @@ When you specify a custom template, only the layout of the original template is </body> </html> ``` + diff --git a/caddy/README.md b/caddy/README.md @@ -50,3 +50,25 @@ 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 + redirect_check_referer false + redirect_host_file ../redirect_hosts.txt +} +``` diff --git a/caddy/demo/Caddyfile b/caddy/demo/Caddyfile @@ -15,6 +15,7 @@ http://localhost:8080 { login { success_url /private htpasswd file=passwords + redirect_host_file redirect_hosts.txt } } diff --git a/caddy/demo/redirect_hosts.txt b/caddy/demo/redirect_hosts.txt @@ -0,0 +1 @@ +www.example.org diff --git a/caddy/demo/webroot/index.html b/caddy/demo/webroot/index.html @@ -26,7 +26,9 @@ <div class="container"> <div class="jumbotron"> <h3>Caddy Login Demo Application</h3> - Please <a href="/login">login</a> with <code>demo/demo</code>. + <div>Please <a href="/login">login</a> as <code>demo/demo</code>.</div> + + <div>Or <a href="/login?backTo=http://www.example.org/">login with redirect</a> as <code>demo/demo</code>.</div> </div> </div> diff --git a/caddy/setup.go b/caddy/setup.go @@ -89,11 +89,11 @@ func parseConfig(c *caddy.Controller) (*login.Config, error) { f := fs.Lookup(name) if f == nil { - return cfg, c.ArgErr() + return cfg, fmt.Errorf("Unknown parameter for login directive: %v (%v:%v)", name, c.File(), c.Line()) } err := f.Value.Set(value) if err != nil { - return cfg, c.Err(err.Error()) + return cfg, fmt.Errorf("Invalid value for parameter %v: %v (%v:%v)", name, value, c.File(), c.Line()) } } diff --git a/caddy/setup_test.go b/caddy/setup_test.go @@ -2,15 +2,15 @@ package caddy import ( "fmt" - "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" + + "github.com/mholt/caddy" + "github.com/mholt/caddy/caddyhttp/httpserver" + . "github.com/stretchr/testify/assert" + "github.com/tarent/loginsrv/login" ) func TestSetup(t *testing.T) { @@ -28,12 +28,15 @@ 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: "/", + Redirect: true, + RedirectQueryParameter: "backTo", + RedirectCheckReferer: true, + LoginPath: "/login", + CookieName: "jwt_token", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -47,6 +50,10 @@ func TestSetup(t *testing.T) { success_url successurl jwt_expiry 42h login_path /foo/bar + redirect true + redirect_query_parameter comingFrom + redirect_check_referer true + redirect_host_file domainWhitelist.txt cookie_name cookiename cookie_http_only false cookie_domain example.com @@ -56,14 +63,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", + Redirect: true, + RedirectQueryParameter: "comingFrom", + RedirectCheckReferer: true, + RedirectHostFile: "domainWhitelist.txt", + 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 +98,15 @@ 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: "/", + Redirect: true, + RedirectQueryParameter: "backTo", + RedirectCheckReferer: true, + LoginPath: "/context/login", + CookieName: "cookiename", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -111,12 +125,15 @@ 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: "/", + Redirect: true, + RedirectQueryParameter: "backTo", + RedirectCheckReferer: true, + LoginPath: "/login", + CookieName: "cookiename", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -133,12 +150,15 @@ 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: "/", + Redirect: true, + RedirectQueryParameter: "backTo", + RedirectCheckReferer: true, + LoginPath: "/login", + CookieName: "jwt_token", + CookieHTTPOnly: true, Backends: login.Options{ "simple": map[string]string{ "bob": "secret", @@ -174,11 +194,14 @@ func TestSetup(t *testing.T) { } } -func TestSetup_RelativeTemplateFile(t *testing.T) { - caddyfile := "loginsrv {\n template myTemplate.tpl\n simple bob=secret\n}" +func TestSetup_RelativeFiles(t *testing.T) { + caddyfile := `loginsrv { + template myTemplate.tpl + redirect_host_file redirectDomains.txt + simple bob=secret + }` root, _ := ioutil.TempDir("", "") - expectedPath := filepath.FromSlash(root + "/myTemplate.tpl") - + c := caddy.NewTestController("http", caddyfile) c.Key = "RelativeTemplateFileTest" config := httpserver.GetConfig(c) @@ -192,5 +215,6 @@ func TestSetup_RelativeTemplateFile(t *testing.T) { } middleware := mids[len(mids)-1](nil).(*CaddyHandler) - Equal(t, expectedPath, middleware.config.Template) + Equal(t, root+"/myTemplate.tpl", middleware.config.Template) + Equal(t, "redirectDomains.txt", middleware.config.RedirectHostFile) } diff --git a/login/config.go b/login/config.go @@ -23,20 +23,24 @@ func init() { // DefaultConfig for the loginsrv handler func DefaultConfig() *Config { return &Config{ - Host: "localhost", - Port: "6789", - LogLevel: "info", - JwtSecret: jwtDefaultSecret, - JwtExpiry: 24 * time.Hour, - JwtRefreshes: 0, - SuccessURL: "/", - LogoutURL: "", - LoginPath: "/login", - CookieName: "jwt_token", - CookieHTTPOnly: true, - Backends: Options{}, - Oauth: Options{}, - GracePeriod: 5 * time.Second, + Host: "localhost", + Port: "6789", + LogLevel: "info", + JwtSecret: jwtDefaultSecret, + JwtExpiry: 24 * time.Hour, + JwtRefreshes: 0, + SuccessURL: "/", + Redirect: true, + RedirectQueryParameter: "backTo", + RedirectCheckReferer: true, + RedirectHostFile: "", + LogoutURL: "", + LoginPath: "/login", + CookieName: "jwt_token", + CookieHTTPOnly: true, + Backends: Options{}, + Oauth: Options{}, + GracePeriod: 5 * time.Second, } } @@ -44,24 +48,28 @@ const envPrefix = "LOGINSRV_" // Config for the loginsrv handler type Config struct { - Host string - Port string - LogLevel string - TextLogging bool - JwtSecret string - JwtExpiry time.Duration - JwtRefreshes int - SuccessURL string - LogoutURL string - Template string - LoginPath string - CookieName string - CookieExpiry time.Duration - CookieDomain string - CookieHTTPOnly bool - Backends Options - Oauth Options - GracePeriod time.Duration + Host string + Port string + LogLevel string + TextLogging bool + JwtSecret string + JwtExpiry time.Duration + JwtRefreshes int + SuccessURL string + Redirect bool + RedirectQueryParameter string + RedirectCheckReferer bool + RedirectHostFile string + LogoutURL string + Template string + LoginPath string + CookieName string + CookieExpiry time.Duration + CookieDomain string + CookieHTTPOnly bool + Backends Options + Oauth Options + GracePeriod time.Duration } // Options is the configuration structure for oauth and backend provider @@ -104,6 +112,11 @@ func (c *Config) ConfigureFlagSet(f *flag.FlagSet) { f.DurationVar(&c.CookieExpiry, "cookie-expiry", c.CookieExpiry, "The expiry duration for the cookie, e.g. 2h or 3h30m. Default is browser session") 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.Redirect, "redirect", c.Redirect, "Allow dynamic overwriting of the the success by query parameter") + f.StringVar(&c.RedirectQueryParameter, "redirect-query-parameter", c.RedirectQueryParameter, "URL parameter for the redirect target") + f.BoolVar(&c.RedirectCheckReferer, "redirect-check-referer", c.RedirectCheckReferer, "When redirecting check that the referer is the same domain") + f.StringVar(&c.RedirectHostFile, "redirect-host-file", c.RedirectHostFile, "A 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") f.StringVar(&c.LoginPath, "login-path", c.LoginPath, "The path of the login resource") diff --git 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,10 @@ func TestConfig_ReadConfig(t *testing.T) { "--jwt-secret=jwtsecret", "--jwt-expiry=42h42m", "--success-url=successurl", + "--redirect=false", + "--redirect-query-parameter=comingFrom", + "--redirect-check-referer=false", + "--redirect-host-file=File", "--logout-url=logouturl", "--template=template", "--login-path=loginpath", @@ -42,20 +47,24 @@ 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", + Redirect: false, + RedirectQueryParameter: "comingFrom", + RedirectCheckReferer: false, + RedirectHostFile: "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 +91,10 @@ 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_REDIRECT", "false")) + NoError(t, os.Setenv("LOGINSRV_REDIRECT_QUERY_PARAMETER", "comingFrom")) + NoError(t, os.Setenv("LOGINSRV_REDIRECT_CHECK_REFERER", "false")) + NoError(t, os.Setenv("LOGINSRV_REDIRECT_HOST_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 +107,24 @@ 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", + Redirect: false, + RedirectQueryParameter: "comingFrom", + RedirectCheckReferer: false, + RedirectHostFile: "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 @@ -4,14 +4,15 @@ import ( "encoding/json" "errors" "fmt" - "github.com/dgrijalva/jwt-go" - "github.com/tarent/loginsrv/logging" - "github.com/tarent/loginsrv/model" - "github.com/tarent/loginsrv/oauth2" "io/ioutil" "net/http" "strings" "time" + + "github.com/dgrijalva/jwt-go" + "github.com/tarent/loginsrv/logging" + "github.com/tarent/loginsrv/model" + "github.com/tarent/loginsrv/oauth2" ) const contentTypeHTML = "text/html; charset=utf-8" @@ -66,6 +67,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + h.setRedirectCookie(w, r) + _, err := h.oauth.GetConfigFromRequest(r) if err == nil { h.handleOauth(w, r) @@ -231,7 +234,8 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u http.SetCookie(w, cookie) - w.Header().Set("Location", h.config.SuccessURL) + w.Header().Set("Location", h.redirectURL(r, w)) + h.deleteRedirectCookie(w, r) w.WriteHeader(303) return } diff --git a/login/handler_test.go b/login/handler_test.go @@ -3,16 +3,17 @@ 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" "net/http" "net/http/httptest" "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" diff --git a/login/redirect.go b/login/redirect.go @@ -0,0 +1,116 @@ +package login + +import ( + "bufio" + "net/http" + "net/url" + "os" + + "github.com/tarent/loginsrv/logging" + "strings" + "time" +) + +func (h *Handler) setRedirectCookie(w http.ResponseWriter, r *http.Request) { + redirectTo := r.URL.Query().Get(h.config.RedirectQueryParameter) + if redirectTo != "" && h.allowRedirect(r) && r.Method != "POST" { + cookie := http.Cookie{ + Name: h.config.RedirectQueryParameter, + Value: redirectTo, + } + http.SetCookie(w, &cookie) + } +} + +func (h *Handler) deleteRedirectCookie(w http.ResponseWriter, r *http.Request) { + _, err := r.Cookie(h.config.RedirectQueryParameter) + if err == nil { + cookie := http.Cookie{ + Name: h.config.RedirectQueryParameter, + Value: "delete", + Expires: time.Unix(0, 0), + } + http.SetCookie(w, &cookie) + } +} + +func (h *Handler) allowRedirect(r *http.Request) bool { + if !h.config.Redirect { + return false + } + if !h.config.RedirectCheckReferer { + return true + } + + 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("redirect from referer domain: '%s', not matching current domain '%s'", referer.Host, r.Host) + return false + } + return true +} + +func (h *Handler) redirectURL(r *http.Request, w http.ResponseWriter) string { + targetURL, foundTarget := h.getRedirectTarget(r) + if foundTarget && h.config.Redirect { + sameHost := targetURL.Host == "" || r.Host == targetURL.Host + if sameHost && targetURL.Path != "" { + return targetURL.Path + } + if !sameHost && h.isRedirectDomainWhitelisted(r, targetURL.Host) { + return targetURL.String() + } + } + return h.config.SuccessURL +} + +func (h *Handler) getRedirectTarget(r *http.Request) (*url.URL, bool) { + cookie, err := r.Cookie(h.config.RedirectQueryParameter) + if err == nil { + url, err := url.Parse(cookie.Value) + if err != nil { + logging.Application(r.Header).Warnf("error parsing redirect URL: %s", err) + return nil, false + } + return url, true + } + + // try reading parameter as it might be a POST request and so not have set the cookie yet + redirectTo := r.URL.Query().Get(h.config.RedirectQueryParameter) + if redirectTo == "" || r.Method != "POST" { + return nil, false + } + url, err := url.Parse(redirectTo) + if err != nil { + logging.Application(r.Header).Warnf("error parsing redirect URL: %s", err) + return nil, false + } + return url, true +} + +func (h *Handler) isRedirectDomainWhitelisted(r *http.Request, host string) bool { + if h.config.RedirectHostFile == "" { + logging.Application(r.Header).Warnf("redirect attempt to '%s', but no whitelist domain file given", host) + return false + } + + f, err := os.Open(h.config.RedirectHostFile) + if err != nil { + logging.Application(r.Header).Warnf("can't open redirect whitelist domains file '%s'", h.config.RedirectHostFile) + return false + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Split(bufio.ScanLines) + for scanner.Scan() { + if host == strings.TrimSpace(scanner.Text()) { + return true + } + } + logging.Application(r.Header).Warnf("redirect attempt to '%s', but not in redirect whitelist", host) + return false +} diff --git a/login/redirect_test.go b/login/redirect_test.go @@ -0,0 +1,126 @@ +package login + +import ( + "net/http/httptest" + "os" + "testing" + + . "github.com/stretchr/testify/assert" + "github.com/tarent/loginsrv/oauth2" + "io/ioutil" +) + +const BadReferer = "Referer: http://evildomain.com" + +func TestRedirect(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")) +} + +func TestRedirect_NotAllowed(t *testing.T) { + // redirect to SuccessURL if Redirect is false + cfg := DefaultConfig() + cfg.Redirect = 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")) +} + +func TestRedirect_NonMatchingReferrer(t *testing.T) { + // 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 RedirectCheckReferer is false + cfg := DefaultConfig() + cfg.RedirectCheckReferer = 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) +} + +func TestRedirect_PreventExternal(t *testing.T) { + // 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")) +} + +func TestRedirect_Whitelisting(t *testing.T) { + whitelistFile, _ := ioutil.TempFile("", "loginsrv_test_domains_whitelist") + whitelistFile.Close() + os.Remove(whitelistFile.Name()) + + // redirect to success url if domains whitelist file doesn't exist + cfg := DefaultConfig() + cfg.RedirectHostFile = whitelistFile.Name() + 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 + domains := []byte("foo.com\ngooddomain.com \nbar.com") + _ = ioutil.WriteFile(whitelistFile.Name(), domains, 0644) + defer os.Remove(whitelistFile.Name()) + + // 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")) + + // still permit access to domains which are not in the 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")) + +}