diff --git a/Makefile b/Makefile index 6841461..b2fdc66 100644 --- a/Makefile +++ b/Makefile @@ -2,4 +2,7 @@ format: gofmt -w -s internal/*.go internal/provider/*.go cmd/*.go -.PHONY: format +test: + go test -v ./... + +.PHONY: format test diff --git a/README.md b/README.md index 03c639e..98319d5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ A minimal forward authentication service that provides OAuth/SSO login and authe - [Operation Modes](#operation-modes) - [Overlay Mode](#overlay-mode) - [Auth Host Mode](#auth-host-mode) + - [Logging Out](#logging-out) - [Copyright](#copyright) - [License](#license) @@ -136,6 +137,7 @@ Application Options: --default-provider=[google|oidc] Default provider (default: google) [$DEFAULT_PROVIDER] --domain= Only allow given email domains, can be set multiple times [$DOMAIN] --lifetime= Lifetime in seconds (default: 43200) [$LIFETIME] + --logout-redirect= URL to redirect to following logout [$LOGOUT_REDIRECT] --url-path= Callback URL Path (default: /_oauth) [$URL_PATH] --secret= Secret used for signing (required) [$SECRET] --whitelist= Only allow given email addresses, can be set multiple times [$WHITELIST] @@ -243,6 +245,10 @@ All options can be supplied in any of the following ways, in the following prece Default: `43200` (12 hours) +- `logout-redirect` + + When set, users will be redirected to this URL following logout. + - `url-path` Customise the path that this service uses to handle the callback following authentication. @@ -443,6 +449,14 @@ Two criteria must be met for an `auth-host` to be used: Please note: For Auth Host mode to work, you must ensure that requests to your auth-host are routed to the traefik-forward-auth container, as demonstrated with the service labels in the [docker-compose-auth.yml](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/swarm/docker-compose-auth-host.yml) example and the [ingressroute resource](https://github.com/thomseddon/traefik-forward-auth/blob/master/examples/traefik-v2/kubernetes/advanced-separate-pod/traefik-forward-auth/ingress.yaml) in a kubernetes example. +### Logging Out + +The service provides an endpoint to clear a users session and "log them out". The path is created by appending `/logout` to your configured `path` and so with the default settings it will be: `/_oauth/logout`. + +You can use the `logout-redirect` config option to redirect users to another URL following logout (note: the user will not have a valid auth cookie after being logged out). + +Note: This only clears the auth cookie from the users browser and as this service is stateless, it does not invalidate the cookie against future use. So if the cookie was recorded, for example, it could continue to be used for the duration of the cookie lifetime. + ## Copyright 2018 Thom Seddon diff --git a/internal/auth.go b/internal/auth.go index b9b1907..66152cf 100644 --- a/internal/auth.go +++ b/internal/auth.go @@ -144,6 +144,19 @@ func MakeCookie(r *http.Request, email string) *http.Cookie { } } +// ClearCookie clears the auth cookie +func ClearCookie(r *http.Request) *http.Cookie { + return &http.Cookie{ + Name: config.CookieName, + Value: "", + Path: "/", + Domain: cookieDomain(r), + HttpOnly: true, + Secure: !config.InsecureCookie, + Expires: time.Now().Local().Add(time.Hour * -1), + } +} + // MakeCSRFCookie makes a csrf cookie (used during login only) func MakeCSRFCookie(r *http.Request, nonce string) *http.Cookie { return &http.Cookie{ diff --git a/internal/config.go b/internal/config.go index 17850cd..20d43f2 100644 --- a/internal/config.go +++ b/internal/config.go @@ -34,6 +34,7 @@ type Config struct { DefaultProvider string `long:"default-provider" env:"DEFAULT_PROVIDER" default:"google" choice:"google" choice:"oidc" description:"Default provider"` Domains CommaSeparatedList `long:"domain" env:"DOMAIN" env-delim:"," description:"Only allow given email domains, can be set multiple times"` LifetimeString int `long:"lifetime" env:"LIFETIME" default:"43200" description:"Lifetime in seconds"` + LogoutRedirect string `long:"logout-redirect" env:"LOGOUT_REDIRECT" description:"URL to redirect to following logout"` Path string `long:"url-path" env:"URL_PATH" default:"/_oauth" description:"Callback URL Path"` SecretString string `long:"secret" env:"SECRET" description:"Secret used for signing (required)" json:"-"` Whitelist CommaSeparatedList `long:"whitelist" env:"WHITELIST" env-delim:"," description:"Only allow given email addresses, can be set multiple times"` diff --git a/internal/config_test.go b/internal/config_test.go index a4e6bc1..85bb1f1 100644 --- a/internal/config_test.go +++ b/internal/config_test.go @@ -33,6 +33,7 @@ func TestConfigDefaults(t *testing.T) { assert.Equal("google", c.DefaultProvider) assert.Len(c.Domains, 0) assert.Equal(time.Second*time.Duration(43200), c.Lifetime) + assert.Equal("", c.LogoutRedirect) assert.Equal("/_oauth", c.Path) assert.Len(c.Whitelist, 0) diff --git a/internal/server.go b/internal/server.go index 2dc1809..3fd7650 100644 --- a/internal/server.go +++ b/internal/server.go @@ -41,6 +41,9 @@ func (s *Server) buildRoutes() { // Add callback handler s.router.Handle(config.Path, s.AuthCallbackHandler()) + // Add logout handler + s.router.Handle(config.Path+"/logout", s.LogoutHandler()) + // Add a default handler if config.DefaultAction == "allow" { s.router.NewRoute().Handler(s.AllowHandler("default")) @@ -180,6 +183,23 @@ func (s *Server) AuthCallbackHandler() http.HandlerFunc { } } +// LogoutHandler logs a user out +func (s *Server) LogoutHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Clear cookie + http.SetCookie(w, ClearCookie(r)) + + logger := s.logger(r, "Logout", "default", "Handling logout") + logger.Info("Logged out user") + + if config.LogoutRedirect != "" { + http.Redirect(w, r, config.LogoutRedirect, http.StatusTemporaryRedirect) + } else { + http.Error(w, "You have been logged out", 401) + } + } +} + func (s *Server) authRedirect(logger *logrus.Entry, w http.ResponseWriter, r *http.Request, p provider.Provider) { // Error indicates no cookie, generate nonce err, nonce := Nonce() diff --git a/internal/server_test.go b/internal/server_test.go index 7f52e7e..be1a766 100644 --- a/internal/server_test.go +++ b/internal/server_test.go @@ -170,6 +170,49 @@ func TestServerAuthCallback(t *testing.T) { assert.Equal("", fwd.Path, "valid request should be redirected to return url") } +func TestServerLogout(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + config = newDefaultConfig() + + req := newDefaultHttpRequest("/_oauth/logout") + res, _ := doHttpRequest(req, nil) + require.Equal(401, res.StatusCode, "should return a 401") + + // Check for cookie + var cookie *http.Cookie + for _, c := range res.Cookies() { + if c.Name == config.CookieName { + cookie = c + } + } + require.NotNil(cookie) + require.Less(cookie.Expires.Local().Unix(), time.Now().Local().Unix()-50, "cookie should have expired") + + // Test with redirect + config.LogoutRedirect = "http://redirect/path" + req = newDefaultHttpRequest("/_oauth/logout") + res, _ = doHttpRequest(req, nil) + require.Equal(307, res.StatusCode, "should return a 307") + + // Check for cookie + cookie = nil + for _, c := range res.Cookies() { + if c.Name == config.CookieName { + cookie = c + } + } + require.NotNil(cookie) + require.Less(cookie.Expires.Local().Unix(), time.Now().Local().Unix()-50, "cookie should have expired") + + fwd, _ := res.Location() + require.NotNil(fwd) + assert.Equal("http", fwd.Scheme, "valid request should be redirected to return url") + assert.Equal("redirect", fwd.Host, "valid request should be redirected to return url") + assert.Equal("/path", fwd.Path, "valid request should be redirected to return url") + +} + func TestServerDefaultAction(t *testing.T) { assert := assert.New(t) config = newDefaultConfig()