mirror of
https://github.com/wahyd4/loginsrv.git
synced 2026-08-09 04:46:29 +10:00
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -15,6 +15,7 @@ http://localhost:8080 {
|
||||
login {
|
||||
success_url /private
|
||||
htpasswd file=passwords
|
||||
redirect_host_file redirect_hosts.txt
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
www.example.org
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user