Merge pull request #64 from tarent/dynamic-redirects

Dynamic redirects
This commit is contained in:
Sebastian Mancke
2018-02-16 09:31:53 +01:00
committed by GitHub
13 changed files with 491 additions and 147 deletions
+49 -32
View File
@@ -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>
```
+22
View File
@@ -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
}
```
+1
View File
@@ -15,6 +15,7 @@ http://localhost:8080 {
login {
success_url /private
htpasswd file=passwords
redirect_host_file redirect_hosts.txt
}
}
+1
View File
@@ -0,0 +1 @@
www.example.org
+3 -1
View File
@@ -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>
+2 -2
View File
@@ -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())
}
}
+66 -42
View File
@@ -2,15 +2,15 @@ package caddy
import (
"fmt"
"io/ioutil"
"os"
"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 +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)
}
+45 -32
View File
@@ -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")
+46 -29
View File
@@ -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",
+9 -5
View File
@@ -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
}
+5 -4
View File
@@ -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"
+116
View File
@@ -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
}
+126
View File
@@ -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"))
}