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:
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.