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 7f32228c029463e01016ace27f7e5454f6c443ca
parent 19fdb618da94b45efb120492cda4eab723ade5de
Author: domano <angor.mail+github@googlemail.com>
Date:   Wed, 31 May 2017 11:57:29 +0200

Merge pull request #23 from domano/feature/implement-jwt-refresh

Implement simple jwt refreshing
Diffstat:
MREADME.md | 6++++++
Mlogin/config.go | 3+++
Mlogin/handler.go | 60+++++++++++++++++++++++++++++++++++++++++++++++-------------
Mlogin/handler_test.go | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmodel/user_info.go | 13+++++++------
5 files changed, 143 insertions(+), 19 deletions(-)

diff --git a/README.md b/README.md @@ -61,6 +61,7 @@ _Note for Caddy users_: Not all parameters are available in Caddy. See the table | -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. | ### Environment Variables @@ -122,6 +123,11 @@ Performs the login and returns the JWT. Depending on the content-type and parame Hint: The status `401 Unauthorized` is not used as a return code to not conflict with an Http BasicAuth Authentication. +#### JWT-Refresh + +If the POST-Parameters for username and password are missing and a valid JWT-Cookie is part of the request, then the JWT-Cookie is refreshed. +This only happens if the jwt-refreshes config option is set to a value greater than 0. + ### DELETE /login Deletes the JWT Cookie. diff --git a/login/config.go b/login/config.go @@ -27,6 +27,7 @@ func DefaultConfig() *Config { LogLevel: "info", JwtSecret: jwtDefaultSecret, JwtExpiry: 24 * time.Hour, + JwtRefreshes: 0, SuccessURL: "/", LogoutURL: "", LoginPath: "/login", @@ -48,6 +49,7 @@ type Config struct { TextLogging bool JwtSecret string JwtExpiry time.Duration + JwtRefreshes int SuccessURL string LogoutURL string Template string @@ -95,6 +97,7 @@ func (c *Config) ConfigureFlagSet(f *flag.FlagSet) { f.BoolVar(&c.TextLogging, "text-logging", c.TextLogging, "Log in text format instead of json") f.StringVar(&c.JwtSecret, "jwt-secret", "random key", "The secret to sign the jwt token") f.DurationVar(&c.JwtExpiry, "jwt-expiry", c.JwtExpiry, "The expiry duration for the jwt token, e.g. 2h or 3h30m") + f.IntVar(&c.JwtRefreshes, "jwt-refreshes", c.JwtRefreshes, "The maximum amount of jwt refreshes. 0 by Default") 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.DurationVar(&c.CookieExpiry, "cookie-expiry", c.CookieExpiry, "The expiry duration for the cookie, e.g. 2h or 3h30m. Default is browser session") diff --git a/login/handler.go b/login/handler.go @@ -109,7 +109,8 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { (r.Method == "POST" && (strings.HasPrefix(contentType, "application/json") || strings.HasPrefix(contentType, "application/x-www-form-urlencoded") || - strings.HasPrefix(contentType, "multipart/form-data")))) { + strings.HasPrefix(contentType, "multipart/form-data") || + contentType == ""))) { h.respondBadRequest(w, r) return } @@ -146,25 +147,49 @@ func (h *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { h.respondBadRequest(w, r) return } - authenticated, userInfo, err := h.authenticate(username, password) - if err != nil { - logging.Application(r.Header).WithError(err).Error() - h.respondError(w, r) + if username != "" { + // No token found or credentials found, assuming new authentication + h.handleAuthentication(w, r, username, password) return } - - if authenticated { - logging.Application(r.Header). - WithField("username", username).Info("successfully authenticated") - h.respondAuthenticated(w, r, userInfo) + userInfo, valid := h.getToken(r) + if valid { + h.handleRefresh(w, r, userInfo) return } - logging.Application(r.Header). - WithField("username", username).Info("failed authentication") + h.respondBadRequest(w, r) + return + } +} + +func (h *Handler) handleAuthentication(w http.ResponseWriter, r *http.Request, username string, password string) { + authenticated, userInfo, err := h.authenticate(username, password) + if err != nil { + logging.Application(r.Header).WithError(err).Error() + h.respondError(w, r) + return + } - h.respondAuthFailure(w, r) + if authenticated { + logging.Application(r.Header). + WithField("username", username).Info("successfully authenticated") + h.respondAuthenticated(w, r, userInfo) return } + logging.Application(r.Header). + WithField("username", username).Info("failed authentication") + + h.respondAuthFailure(w, r) +} + +func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request, userInfo model.UserInfo) { + if userInfo.Refreshes >= h.config.JwtRefreshes { + h.respondMaxRefreshesReached(w, r) + } else { + userInfo.Refreshes++ + h.respondAuthenticated(w, r, userInfo) + logging.Application(r.Header).WithField("username", userInfo.Sub).Info("refreshed jwt") + } } func (h *Handler) deleteToken(w http.ResponseWriter) { @@ -189,6 +214,7 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u h.respondError(w, r) return } + if wantHTML(r) { cookie := &http.Cookie{ Name: h.config.CookieName, @@ -202,7 +228,9 @@ func (h *Handler) respondAuthenticated(w http.ResponseWriter, r *http.Request, u if h.config.CookieDomain != "" { cookie.Domain = h.config.CookieDomain } + http.SetCookie(w, cookie) + w.Header().Set("Location", h.config.SuccessURL) w.WriteHeader(303) return @@ -265,6 +293,11 @@ func (h *Handler) respondNotFound(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Not Found: The requested page does not exist") } +func (h *Handler) respondMaxRefreshesReached(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(403) + fmt.Fprint(w, "Max JWT refreshes reached") +} + func (h *Handler) respondAuthFailure(w http.ResponseWriter, r *http.Request) { if wantHTML(r) { w.Header().Set("Content-Type", contentTypeHTML) @@ -278,6 +311,7 @@ func (h *Handler) respondAuthFailure(w http.ResponseWriter, r *http.Request) { }) return } + w.Header().Set("Content-Type", contentTypePlain) w.WriteHeader(403) fmt.Fprintf(w, "Wrong credentials") diff --git a/login/handler_test.go b/login/handler_test.go @@ -25,6 +25,7 @@ func testConfig() *Config { testConfig.LoginPath = "/context/login" testConfig.CookieDomain = "example.com" testConfig.CookieExpiry = 23 * time.Hour + testConfig.JwtRefreshes = 1 return testConfig } @@ -237,6 +238,85 @@ func TestHandler_LoginWeb(t *testing.T) { Equal(t, recorder.Header().Get("Set-Cookie"), "") } +func TestHandler_Refresh(t *testing.T) { + h := testHandler() + input := model.UserInfo{Sub: "bob", Expiry: time.Now().Add(time.Second).Unix()} + token, err := h.createToken(input) + NoError(t, err) + + cookieStr := "Cookie: "+h.config.CookieName + "=" + token + ";" + + // refreshSuccess + recorder := call(req("POST", "/context/login", "", AcceptHTML, cookieStr)) + Equal(t, 303, recorder.Code) + + // verify the token from the cookie + setCookieList := readSetCookies(recorder.Header()) + Equal(t, 1, len(setCookieList)) + + cookie := setCookieList[0] + Equal(t, "jwt_token", cookie.Name) + Equal(t, "/", cookie.Path) + Equal(t, "example.com", cookie.Domain) + InDelta(t, time.Now().Add(testConfig().CookieExpiry).Unix(), cookie.Expires.Unix(), 2) + True(t, cookie.HttpOnly) + + // check the token content + claims, err := tokenAsMap(cookie.Value) + NoError(t, err) + Equal(t, "bob", claims["sub"]) + InDelta(t, time.Now().Add(DefaultConfig().JwtExpiry).Unix(), claims["exp"], 2) +} + +func TestHandler_Refresh_Expired(t *testing.T) { + h := testHandler() + input := model.UserInfo{Sub: "bob", Expiry: time.Now().Unix()-1} + token, err := h.createToken(input) + NoError(t, err) + + cookieStr := "Cookie: "+h.config.CookieName + "=" + token + ";" + + // refreshSuccess + recorder := call(req("POST", "/context/login", "", AcceptHTML, cookieStr)) + Equal(t, 400, recorder.Code) + + // verify the token from the cookie + setCookieList := readSetCookies(recorder.Header()) + Equal(t, 0, len(setCookieList)) +} + +func TestHandler_Refresh_Invalid_Token(t *testing.T) { + h := testHandler() + + cookieStr := "Cookie: "+h.config.CookieName + "=kjsbkabsdkjbasdbkasbdk.dfgdfg.fdgdfg;" + + // refreshSuccess + recorder := call(req("POST", "/context/login", "", AcceptHTML, cookieStr)) + Equal(t, 400, recorder.Code) + + // verify the token from the cookie + setCookieList := readSetCookies(recorder.Header()) + Equal(t, 0, len(setCookieList)) +} + +func TestHandler_Refresh_Max_Refreshes_Reached(t *testing.T) { + h := testHandler() + input := model.UserInfo{Sub: "bob", Expiry: time.Now().Add(time.Second).Unix(), Refreshes:1} + token, err := h.createToken(input) + NoError(t, err) + + cookieStr := "Cookie: "+h.config.CookieName + "=" + token + ";" + + // refreshSuccess + recorder := call(req("POST", "/context/login", "", AcceptJwt, cookieStr)) + Equal(t, 403, recorder.Code) + Contains(t, recorder.Body.String(), "reached") + + // verify the token from the cookie + setCookieList := readSetCookies(recorder.Header()) + Equal(t, 0, len(setCookieList)) +} + func TestHandler_Logout(t *testing.T) { // DELETE recorder := call(req("DELETE", "/context/login", "")) diff --git a/model/user_info.go b/model/user_info.go @@ -8,12 +8,13 @@ import ( // UserInfo holds the parameters returned by the backends. // This information will be serialized to build the JWT token contents. type UserInfo struct { - Sub string `json:"sub"` - Picture string `json:"picture,omitempty"` - Name string `json:"name,omitempty"` - Email string `json:"email,omitempty"` - Origin string `json:"origin,omitempty"` - Expiry int64 `json:"exp,omitempty"` + Sub string `json:"sub"` + Picture string `json:"picture,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Origin string `json:"origin,omitempty"` + Expiry int64 `json:"exp,omitempty"` + Refreshes int `json:"refs,omitempty"` } // Valid lets us use the user info as Claim for jwt-go.