diff --git a/README.md b/README.md index 05a2986..5b1a442 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,10 @@ Returns a simple bootstrap styled login form. The returned html follows the ui composition conventions from (lib-compose)[https://github.com/tarent/lib-compose], so it can be embedded into an existing layout. +### GET /login/ + +Starts the Oauth Web Flow with the configured provider. E.g. `GET /login/github` redirects to the github login form. + ### POST /login Does the login and returns the JWT. Depending on the content-type, and parameters a classical JSON-Rest or a redirect can be performed. @@ -108,6 +112,12 @@ Does the login and returns the JWT. Depending on the content-type, and parameter Hint: The status `401 Unauthorized` is not used as a return code to not conflict with an Http BasicAuth Authentication. +### DELETE /login + +Deletes the JWT Cookie. + +For simple usage in web applications, this can also be called by `GET|POST /login?logout=true` + #### Example: Default is to return the token as Content-Type application/jwt within the body. ``` diff --git a/login/handler.go b/login/handler.go index 9ee429c..4117034 100644 --- a/login/handler.go +++ b/login/handler.go @@ -10,6 +10,7 @@ import ( "io/ioutil" "net/http" "strings" + "time" ) const contentTypeHtml = "text/html; charset=utf-8" @@ -97,7 +98,7 @@ func (h *Handler) handleOauth(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { contentType := r.Header.Get("Content-Type") - if !(r.Method == "GET" || + if !(r.Method == "GET" || r.Method == "DELETE" || (r.Method == "POST" && (strings.HasPrefix(contentType, "application/json") || strings.HasPrefix(contentType, "application/x-www-form-urlencoded") || @@ -107,11 +108,24 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { } r.ParseForm() - if r.Method == "GET" { + if r.Method == "DELETE" || r.FormValue("logout") == "true" { + h.deleteToken(w) writeLoginForm(w, - map[string]interface{}{ - "path": r.URL.Path, - "config": h.config, + loginFormData{ + Path: r.URL.Path, + Config: h.config, + }) + return + } + + if r.Method == "GET" { + userInfo, valid := h.getToken(r) + writeLoginForm(w, + loginFormData{ + Path: r.URL.Path, + Config: h.config, + Authenticated: valid, + UserInfo: userInfo, }) return } @@ -143,6 +157,17 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { } } +func (h *Handler) deleteToken(w http.ResponseWriter) { + cookie := &http.Cookie{ + Name: h.config.CookieName, + Value: "delete", + HttpOnly: true, + Expires: time.Unix(0, 0), + Path: "/", + } + http.SetCookie(w, cookie) +} + func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, userInfo jwt.Claims) { token, err := h.createToken(userInfo) if err != nil { @@ -152,7 +177,12 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u } if wantHtml(r) { // TODO: set livetime - cookie := &http.Cookie{Name: h.config.CookieName, Value: token, HttpOnly: true} + cookie := &http.Cookie{ + Name: h.config.CookieName, + Value: token, + HttpOnly: true, + Path: "/", + } http.SetCookie(w, cookie) w.Header().Set("Location", h.config.SuccessUrl) w.WriteHeader(303) @@ -169,17 +199,32 @@ func (h *Handler) createToken(userInfo jwt.Claims) (string, error) { return token.SignedString([]byte(h.config.JwtSecret)) } +func (h *Handler) getToken(r *http.Request) (userInfo UserInfo, valid bool) { + c, err := r.Cookie(h.config.CookieName) + if err != nil { + return UserInfo{}, false + } + + token, err := jwt.ParseWithClaims(c.Value, &UserInfo{}, func(*jwt.Token) (interface{}, error) { + return []byte(h.config.JwtSecret), nil + }) + if err != nil { + return UserInfo{}, false + } + + u, v := token.Claims.(*UserInfo) + return *u, v +} + func (h *Handler) respondError(w http.ResponseWriter, r *http.Request) { if wantHtml(r) { - w.Header().Set("Content-Type", contentTypeHtml) - w.WriteHeader(500) username, _, _ := getCredentials(r) writeLoginForm(w, - map[string]interface{}{ - "path": r.URL.Path, - "error": true, - "config": h.config, - "username": username, + loginFormData{ + Path: r.URL.Path, + Error: true, + Config: h.config, + UserInfo: UserInfo{Username: username}, }) return } @@ -199,12 +244,11 @@ func (h *Handler) respondAuthFailure(w http.ResponseWriter, r *http.Request) { w.WriteHeader(403) username, _, _ := getCredentials(r) writeLoginForm(w, - map[string]interface{}{ - "path": r.URL.Path, - "failure": true, - "config": h.config, - - "username": username, + loginFormData{ + Path: r.URL.Path, + Failure: true, + Config: h.config, + UserInfo: UserInfo{Username: username}, }) return } diff --git a/login/handler_test.go b/login/handler_test.go index f75f6a7..14bf459 100644 --- a/login/handler_test.go +++ b/login/handler_test.go @@ -109,6 +109,7 @@ func TestHandler_LoginWeb(t *testing.T) { claims, err := tokenAsMap(strings.SplitN(headerParts[1], ";", 2)[0]) assert.NoError(t, err) assert.Equal(t, map[string]interface{}{"sub": "bob"}, claims) + assert.Contains(t, headerParts[1]+";", "Path=/;") // show the login form again after authentication failed recorder = call(req("POST", "/context/login", "username=bob&password=FOOBAR", TypeForm, AcceptHtml)) @@ -119,6 +120,23 @@ func TestHandler_LoginWeb(t *testing.T) { assert.Equal(t, recorder.Header().Get("Set-Cookie"), "") } +func TestHandler_Logout(t *testing.T) { + // DELETE + recorder := call(req("DELETE", "/context/login", "")) + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Header().Get("Set-Cookie"), "jwt_token=delete; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;") + + // GET + param + recorder = call(req("GET", "/context/login?logout=true", "")) + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Header().Get("Set-Cookie"), "jwt_token=delete; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;") + + // POST + param + recorder = call(req("POST", "/context/login", "logout=true", TypeForm)) + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Header().Get("Set-Cookie"), "jwt_token=delete; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT;") +} + func TestHandler_LoginError(t *testing.T) { h := testHandlerWithError() @@ -142,6 +160,51 @@ func TestHandler_LoginError(t *testing.T) { assert.Contains(t, recorder.Body.String(), "Internal Error") } +func TestHandler_getToken_Valid(t *testing.T) { + h := testHandler() + input := UserInfo{Username: "marvin"} + token, err := h.createToken(input) + assert.NoError(t, err) + r := &http.Request{ + Header: http.Header{"Cookie": {h.config.CookieName + "=" + token + ";"}}, + } + userInfo, valid := h.getToken(r) + assert.True(t, valid) + assert.Equal(t, input, userInfo) +} + +func TestHandler_getToken_InvalidSecret(t *testing.T) { + h := testHandler() + input := UserInfo{Username: "marvin"} + token, err := h.createToken(input) + assert.NoError(t, err) + r := &http.Request{ + Header: http.Header{"Cookie": {h.config.CookieName + "=" + token + ";"}}, + } + + // modify secret + h.config.JwtSecret = "foobar" + + _, valid := h.getToken(r) + assert.False(t, valid) +} + +func TestHandler_getToken_InvalidToken(t *testing.T) { + h := testHandler() + r := &http.Request{ + Header: http.Header{"Cookie": {h.config.CookieName + "=asdcsadcsadc"}}, + } + + _, valid := h.getToken(r) + assert.False(t, valid) +} + +func TestHandler_getToken_InvalidNoToken(t *testing.T) { + h := testHandler() + _, valid := h.getToken(&http.Request{}) + assert.False(t, valid) +} + func testHandler() *Handler { return &Handler{ backends: []Backend{ diff --git a/login/login_form.go b/login/login_form.go index cea390b..c5513ec 100644 --- a/login/login_form.go +++ b/login/login_form.go @@ -1,54 +1,123 @@ package login import ( + "bytes" + "github.com/tarent/lib-compose/logging" "html/template" - "io" + "net/http" ) const loginForm = ` + + -
-
-
-
-
-

Please sign in

- {{ if .error}}Internal Error. Please try again later{{end}} - {{ if .failure}}Wrong credentials{{end}} -
-
-
-
-
- -
-
- -
- -
-
-
-
-
+
+
+
+ + {{ if .Error}} + + {{end}} + + {{ if .Authenticated}} +

Welcome {{.UserInfo.Username}}

+ Logout + {{else}} + + Sign in with Github + + +
+
+ +
+

Sign in

+ {{ if .Failure}}{{end}} +
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+ {{end}} +
-
+
- -` +` -func writeLoginForm(w io.Writer, params map[string]interface{}) { +type loginFormData struct { + Path string + Error bool + Failure bool + Config *Config + Authenticated bool + UserInfo UserInfo +} + +func writeLoginForm(w http.ResponseWriter, params loginFormData) { t := template.Must(template.New("loginForm").Parse(loginForm)) - t.Execute(w, params) + b := bytes.NewBuffer(nil) + err := t.Execute(b, params) + if err != nil { + logging.Logger.WithError(err).Error() + w.WriteHeader(500) + w.Write([]byte(`Internal Server Error`)) + return + } + + w.Header().Set("Content-Type", contentTypeHtml) + if params.Error { + w.WriteHeader(500) + } + + w.Write(b.Bytes()) } diff --git a/oauth2/github.go b/oauth2/github.go index 92b0908..28f1423 100644 --- a/oauth2/github.go +++ b/oauth2/github.go @@ -13,6 +13,6 @@ var providerGithub = Provider{ GetUserInfo: func(token TokenInfo) (map[string]string, error) { //http.Get(fmt.Sprintf("%v/user?access_token=%v"), githubApi, token.AccessToken) // https://developer.github.com/v3/users/ - return nil, nil + return map[string]string{"username": "demo"}, nil }, }