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 dd834a28e85e4f61403080bf8ce4130c67185de3
parent 0444d0b57159b9dddc4e57e1ac4e9f49bf07eba2
Author: Sebastian Mancke <s.mancke@tarent.de>
Date:   Fri, 12 May 2017 14:16:19 +0200

Merge pull request #12 from tarent/configurable-template

Configurable template
Diffstat:
MREADME.md | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mcaddy/setup.go | 5+++++
Mcaddy/setup_test.go | 22++++++++++++++++++++++
Mlogin/config.go | 6+++++-
Mlogin/config_test.go | 8++++++++
Mlogin/handler.go | 5+++++
Mlogin/handler_test.go | 27+++++++++++++++++++++------
Mlogin/login_form.go | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mlogin/login_form_test.go | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Moauth2/github.go | 1-
10 files changed, 315 insertions(+), 28 deletions(-)

diff --git a/README.md b/README.md @@ -54,6 +54,10 @@ The configuration parameters are as follows. The secret to sign the jwt token (default "random key") -log-level string The log level (default "info") + -login-path string + The path of the login resource (default "/login") + -logout-url string + The url or path to redirect after logout -osiam value Osiam login backend opts: endpoint=..,client_id=..,client_secret=.. -port string @@ -62,6 +66,8 @@ The configuration parameters are as follows. Simple login backend opts: user1=password,user2=password,.. -success-url string The url to redirect after login (default "/") + -template string + An alternative template for the login form -text-logging Log in text format instead of json ``` @@ -237,3 +243,48 @@ if loginsrv is routed through a reverse proxy, if the headers `X-Forwarded-Host` ``` $ docker run -p 80:80 tarent/loginsrv -github client_id=xxx,client_secret=yyy ``` + +## Templating + +A custom template can be supplied by the paramter `template`. +You can find the original template in [login/login_form.go](https://github.com/tarent/loginsrv/blob/master/login/login_form.go). + +The templating uses the golang build in template language. A short intro can be found [here](https://astaxie.gitbooks.io/build-web-application-with-golang/en/07.4.html). + +When you specify a custom template, only the layout of the original template is replaced. The partials of the original are still loaded into +the tempalte context and can be used by your template. So a minimal unstyled login template could look like this: + +``` +<!DOCTYPE html> +<html> + <head> + <!-- your styles --> + <head> + <body> + <!-- your header --> + + {{ if .Error}} + <div class="alert alert-danger" role="alert"> + <strong>Internal Error. </strong> Please try again later. + </div> + {{end}} + + {{if .Authenticated}} + + {{template "userInfo" . }} + + {{else}} + + {{template "login" . }} + + {{end}} + + <!-- your footer --> +</body> +</html> +``` + + + + + diff --git a/caddy/setup.go b/caddy/setup.go @@ -12,6 +12,7 @@ import ( _ "github.com/tarent/loginsrv/osiam" "os" "path" + "path/filepath" "strings" ) @@ -34,6 +35,10 @@ func setup(c *caddy.Controller) error { return err } + if config.Template != "" && !filepath.IsAbs(config.Template) { + config.Template = filepath.Join(httpserver.GetConfig(c).Root, config.Template) + } + if len(args) == 1 { logging.Logger.Warnf("DEPRECATED: Please set the loing path by parameter login_path and not as directive argument (%v:%v)", c.File(), c.Line()) config.LoginPath = path.Join(args[0], "/login") diff --git a/caddy/setup_test.go b/caddy/setup_test.go @@ -5,6 +5,7 @@ import ( "github.com/mholt/caddy/caddyhttp/httpserver" "github.com/stretchr/testify/assert" "github.com/tarent/loginsrv/login" + "io/ioutil" "os" "testing" ) @@ -134,3 +135,24 @@ func TestSetup(t *testing.T) { assert.Equal(t, &test.config, middleware.config) } } + +func TestSetup_RelativeTemplateFile(t *testing.T) { + caddyfile := "loginsrv {\n template myTemplate.tpl\n simple bob=secret\n}" + root, _ := ioutil.TempDir("", "") + expectedPath := root + "/myTemplate.tpl" + + c := caddy.NewTestController("http", caddyfile) + c.Key = "RelativeTemplateFileTest" + config := httpserver.GetConfig(c) + config.Root = root + + err := setup(c) + assert.NoError(t, err) + mids := httpserver.GetConfig(c).Middleware() + if len(mids) == 0 { + t.Errorf("no middlewares created") + } + middleware := mids[len(mids)-1](nil).(*CaddyHandler) + + assert.Equal(t, expectedPath, middleware.config.Template) +} diff --git a/login/config.go b/login/config.go @@ -26,6 +26,7 @@ func DefaultConfig() *Config { LogLevel: "info", JwtSecret: jwtDefaultSecret, SuccessUrl: "/", + LogoutUrl: "", LoginPath: "/login", CookieName: "jwt_token", CookieHttpOnly: true, @@ -43,6 +44,8 @@ type Config struct { TextLogging bool JwtSecret string SuccessUrl string + LogoutUrl string + Template string LoginPath string CookieName string CookieHttpOnly bool @@ -86,6 +89,8 @@ func (c *Config) ConfigureFlagSet(f *flag.FlagSet) { f.StringVar(&c.CookieName, "cookie-name", c.CookieName, "The name of the jwt cookie") f.BoolVar(&c.CookieHttpOnly, "cookie-http-only", c.CookieHttpOnly, "Set the cookie with the http only flag") f.StringVar(&c.SuccessUrl, "success-url", c.SuccessUrl, "The url to redirect after login") + 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") // the -backends is deprecated, but we support it for backwards compatibility @@ -119,7 +124,6 @@ func (c *Config) ConfigureFlagSet(f *flag.FlagSet) { for _, pName := range ProviderList() { func(pName string) { setter := setFunc(func(optsKvList string) error { - fmt.Printf("set %v\n", pName) return c.addBackendOpts(pName, optsKvList) }) desc, _ := GetProviderDescription(pName) diff --git a/login/config_test.go b/login/config_test.go @@ -26,6 +26,8 @@ func TestConfig_ReadConfig(t *testing.T) { "--text-logging=true", "--jwt-secret=jwtsecret", "--success-url=successurl", + "--logout-url=logouturl", + "--template=template", "--login-path=loginpath", "--cookie-name=cookiename", "--cookie-http-only=false", @@ -41,6 +43,8 @@ func TestConfig_ReadConfig(t *testing.T) { TextLogging: true, JwtSecret: "jwtsecret", SuccessUrl: "successurl", + LogoutUrl: "logouturl", + Template: "template", LoginPath: "loginpath", CookieName: "cookiename", CookieHttpOnly: false, @@ -68,6 +72,8 @@ func TestConfig_ReadConfigFromEnv(t *testing.T) { assert.NoError(t, os.Setenv("LOGINSRV_TEXT_LOGGING", "true")) assert.NoError(t, os.Setenv("LOGINSRV_JWT_SECRET", "jwtsecret")) assert.NoError(t, os.Setenv("LOGINSRV_SUCCESS_URL", "successurl")) + assert.NoError(t, os.Setenv("LOGINSRV_LOGOUT_URL", "logouturl")) + assert.NoError(t, os.Setenv("LOGINSRV_TEMPLATE", "template")) assert.NoError(t, os.Setenv("LOGINSRV_LOGIN_PATH", "loginpath")) assert.NoError(t, os.Setenv("LOGINSRV_COOKIE_NAME", "cookiename")) assert.NoError(t, os.Setenv("LOGINSRV_COOKIE_HTTP_ONLY", "false")) @@ -81,6 +87,8 @@ func TestConfig_ReadConfigFromEnv(t *testing.T) { TextLogging: true, JwtSecret: "jwtsecret", SuccessUrl: "successurl", + LogoutUrl: "logouturl", + Template: "template", LoginPath: "loginpath", CookieName: "cookiename", CookieHttpOnly: false, diff --git a/login/handler.go b/login/handler.go @@ -115,6 +115,11 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { r.ParseForm() if r.Method == "DELETE" || r.FormValue("logout") == "true" { h.deleteToken(w) + if h.config.LogoutUrl != "" { + w.Header().Set("Location", h.config.LogoutUrl) + w.WriteHeader(303) + return + } writeLoginForm(w, loginFormData{ Config: h.config, diff --git a/login/handler_test.go b/login/handler_test.go @@ -29,10 +29,10 @@ func TestHandler_NewFromConfig(t *testing.T) { { &Config{ Backends: Options{ - "simple": map[string]string{"bob": "secret"}, + "simple": {"bob": "secret"}, }, Oauth: Options{ - "github": map[string]string{"client_id": "xxx", "client_secret": "YYY"}, + "github": {"client_id": "xxx", "client_secret": "YYY"}, }, }, 1, @@ -40,7 +40,7 @@ func TestHandler_NewFromConfig(t *testing.T) { false, }, { - &Config{Backends: Options{"simple": map[string]string{"bob": "secret"}}}, + &Config{Backends: Options{"simple": {"bob": "secret"}}}, 1, 0, false, @@ -48,7 +48,7 @@ func TestHandler_NewFromConfig(t *testing.T) { // error cases { // init error because no users are provided - &Config{Backends: Options{"simple": map[string]string{}}}, + &Config{Backends: Options{"simple": {}}}, 1, 0, true, @@ -56,7 +56,7 @@ func TestHandler_NewFromConfig(t *testing.T) { { &Config{ Oauth: Options{ - "FOOO": map[string]string{"client_id": "xxx", "client_secret": "YYY"}, + "FOOO": {"client_id": "xxx", "client_secret": "YYY"}, }, }, 0, @@ -70,7 +70,7 @@ func TestHandler_NewFromConfig(t *testing.T) { true, }, { - &Config{Backends: Options{"simpleFoo": map[string]string{"bob": "secret"}}}, + &Config{Backends: Options{"simpleFoo": {"bob": "secret"}}}, 1, 0, true, @@ -238,6 +238,21 @@ func TestHandler_Logout(t *testing.T) { assert.Equal(t, "no-cache, no-store, must-revalidate", recorder.Header().Get("Cache-Control")) } +func TestHandler_CustomLogoutUrl(t *testing.T) { + cfg := DefaultConfig() + cfg.LogoutUrl = "http://example.com" + h := &Handler{ + oauth: oauth2.NewManager(), + config: cfg, + } + + recorder := httptest.NewRecorder() + h.ServeHTTP(recorder, req("DELETE", "/login", "")) + assert.Contains(t, recorder.Header().Get("Set-Cookie"), "jwt_token=delete; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;") + assert.Equal(t, 303, recorder.Code) + assert.Equal(t, "http://example.com", recorder.Header().Get("Location")) +} + func TestHandler_LoginError(t *testing.T) { h := testHandlerWithError() diff --git a/login/login_form.go b/login/login_form.go @@ -5,13 +5,14 @@ import ( "github.com/tarent/lib-compose/logging" "github.com/tarent/loginsrv/model" "html/template" + "io/ioutil" "net/http" "strings" ) -const loginForm = `<!DOCTYPE html> -<html> - <head> +const partials = ` + +{{define "styles"}} <link uic-remove rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css"> <link uic-remove rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-social/5.1.1/bootstrap-social.min.css"> <link uic-remove rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css"> @@ -47,29 +48,20 @@ const loginForm = `<!DOCTYPE html> border-radius: 3px; } </style> - </head> - <body> - <uic-fragment name="content"> - <div class="container"> - <div class="row vertical-offset-100"> - <div class="col-md-4 col-md-offset-4"> - - {{ if .Error}} - <div class="alert alert-danger" role="alert"> - <strong>Internal Error. </strong> Please try again later. - </div> - {{end}} +{{end}} - {{ if .Authenticated}} +{{define "userInfo"}} {{with .UserInfo}} <h1>Welcome {{.Sub}}!</h1> <br/> {{if .Picture}}<img class="login-picture" src="{{.Picture}}?s=120">{{end}} {{if .Name}}<h3>{{.Name}}</h3>{{end}} {{end}} - <br/> - <a class="btn btn-md btn-primary" href="{{ .Config.LoginPath }}?logout=true">Logout</a> - {{else}} + <br/> + <a class="btn btn-md btn-primary" href="{{ .Config.LoginPath }}?logout=true">Logout</a> +{{end}} + +{{define "login"}} {{ range $providerName, $opts := .Config.Oauth }} <a class="btn btn-block btn-lg btn-social btn-{{ $providerName }}" href="{{ $.Config.LoginPath }}/{{ $providerName }}"> <span class="fa fa-{{ $providerName }}"></span> Sign in with {{ $providerName | ucfirst }} @@ -106,6 +98,33 @@ const loginForm = `<!DOCTYPE html> </div> </div> {{end}} +{{end}}` + +var layout = `<!DOCTYPE html> +<html> + <head> + {{ template "styles" . }} + </head> + <body> + <uic-fragment name="content"> + <div class="container"> + <div class="row vertical-offset-100"> + <div class="col-md-4 col-md-offset-4"> + + {{ if .Error}} + <div class="alert alert-danger" role="alert"> + <strong>Internal Error. </strong> Please try again later. + </div> + {{end}} + + {{if .Authenticated}} + + {{template "userInfo" . }} + + {{else}} + + {{template "login" . }} + {{end}} </div> </div> @@ -126,7 +145,32 @@ func writeLoginForm(w http.ResponseWriter, params loginFormData) { funcMap := template.FuncMap{ "ucfirst": ucfirst, } - t := template.Must(template.New("loginForm").Funcs(funcMap).Parse(loginForm)) + templateName := "loginForm" + if params.Config != nil && params.Config.Template != "" { + templateName = params.Config.Template + } + t := template.New(templateName).Funcs(funcMap) + t = template.Must(t.Parse(partials)) + if params.Config != nil && params.Config.Template != "" { + customTemplate, err := ioutil.ReadFile(params.Config.Template) + if err != nil { + logging.Logger.WithError(err).Error() + w.WriteHeader(500) + w.Write([]byte(`Internal Server Error`)) + return + } + + t, err = t.Parse(string(customTemplate)) + if err != nil { + logging.Logger.WithError(err).Error() + w.WriteHeader(500) + w.Write([]byte(`Internal Server Error`)) + return + } + } else { + t = template.Must(t.Parse(layout)) + } + b := bytes.NewBuffer(nil) err := t.Execute(b, params) if err != nil { diff --git a/login/login_form_test.go b/login/login_form_test.go @@ -2,9 +2,143 @@ package login import ( "github.com/stretchr/testify/assert" + "github.com/tarent/loginsrv/model" + "io/ioutil" + "net/http/httptest" + "os" "testing" ) +func Test_form(t *testing.T) { + // show error + recorder := httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Error: true, + Config: &Config{ + LoginPath: "/login", + Backends: Options{"simple": {}}, + }, + }) + assert.Contains(t, recorder.Body.String(), `<form`) + assert.NotContains(t, recorder.Body.String(), `github`) + assert.NotContains(t, recorder.Body.String(), `Welcome`) + assert.Contains(t, recorder.Body.String(), `Error`) + + // only form + recorder = httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Config: &Config{ + LoginPath: "/login", + Backends: Options{"simple": {}}, + }, + }) + assert.Contains(t, recorder.Body.String(), `<form`) + assert.NotContains(t, recorder.Body.String(), `github`) + assert.NotContains(t, recorder.Body.String(), `Welcome`) + assert.NotContains(t, recorder.Body.String(), `Error`) + + // only links + recorder = httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Config: &Config{ + LoginPath: "/login", + Oauth: Options{"github": {}}, + }, + }) + assert.NotContains(t, recorder.Body.String(), `<form`) + assert.Contains(t, recorder.Body.String(), `href="/login/github"`) + assert.NotContains(t, recorder.Body.String(), `Welcome`) + assert.NotContains(t, recorder.Body.String(), `Error`) + + // with form and links + recorder = httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Config: &Config{ + LoginPath: "/login", + Backends: Options{"simple": {}}, + Oauth: Options{"github": {}}, + }, + }) + assert.Contains(t, recorder.Body.String(), `<form`) + assert.Contains(t, recorder.Body.String(), `href="/login/github"`) + assert.NotContains(t, recorder.Body.String(), `Welcome`) + assert.NotContains(t, recorder.Body.String(), `Error`) + + // show only the user info + recorder = httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Authenticated: true, + UserInfo: model.UserInfo{Sub: "smancke", Name: "Sebastian Mancke"}, + Config: &Config{ + LoginPath: "/login", + Backends: Options{"simple": {}}, + Oauth: Options{"github": {}}, + }, + }) + assert.NotContains(t, recorder.Body.String(), `<form`) + assert.NotContains(t, recorder.Body.String(), `href="/login/github"`) + assert.Contains(t, recorder.Body.String(), `Welcome smancke`) + assert.NotContains(t, recorder.Body.String(), `Error`) +} + +func Test_form_executeError(t *testing.T) { + recorder := httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{}) + assert.Equal(t, 500, recorder.Code) +} + +func Test_form_customTemplate(t *testing.T) { + f, err := ioutil.TempFile("", "") + assert.NoError(t, err) + f.WriteString(`<html><body>My custom template {{template "login" .}}</body></html>`) + f.Close() + defer os.Remove(f.Name()) + + recorder := httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Error: true, + Config: &Config{ + LoginPath: "/login", + Backends: Options{"simple": {}}, + Template: f.Name(), + }, + }) + assert.Contains(t, recorder.Body.String(), `My custom template`) + assert.Contains(t, recorder.Body.String(), `<form`) + assert.NotContains(t, recorder.Body.String(), `github`) + assert.NotContains(t, recorder.Body.String(), `Welcome`) + assert.NotContains(t, recorder.Body.String(), `Error`) + assert.NotContains(t, recorder.Body.String(), `style`) +} + +func Test_form_customTemplate_ParseError(t *testing.T) { + f, err := ioutil.TempFile("", "") + assert.NoError(t, err) + f.WriteString(`<html><body>My custom template {{template "login" `) + f.Close() + defer os.Remove(f.Name()) + + recorder := httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Config: &Config{ + LoginPath: "/login", + Backends: Options{"simple": {}}, + Template: f.Name(), + }, + }) + assert.Equal(t, 500, recorder.Code) +} + +func Test_form_customTemplate_MissingFile(t *testing.T) { + recorder := httptest.NewRecorder() + writeLoginForm(recorder, loginFormData{ + Config: &Config{ + Template: "/this/file/does/not/exist", + }, + }) + assert.Equal(t, 500, recorder.Code) +} + func Test_ucfirst(t *testing.T) { assert.Equal(t, "", ucfirst("")) assert.Equal(t, "A", ucfirst("a")) diff --git a/oauth2/github.go b/oauth2/github.go @@ -29,7 +29,6 @@ var providerGithub = Provider{ GetUserInfo: func(token TokenInfo) (model.UserInfo, string, error) { gu := GithubUser{} url := fmt.Sprintf("%v/user?access_token=%v", githubApi, token.AccessToken) - fmt.Println("url: ", url) resp, err := http.Get(url) if err != nil { return model.UserInfo{}, "", err