add subtree renderer, fix static routing for index pages, don't log sessionId when authenticating, and add token auth
Iris Lightshard nilix@nilfm.cc
PGP Signature
-----BEGIN PGP SIGNATURE----- iQIzBAABCAAdFiEEkFh6dA+k/6CXFXU4O3+8IhROY5gFAmLnGWYACgkQO3+8IhRO Y5jnAw//SHseLp2hEsXxV7Z8b+83YpjSSg+shNH3feuQmnPNaxM/aj2+xJMQMhDR 5y/mEYEgzpyCYPPNzBfaENBCLgKzpHrPsWYMYkAedFioAbfnT9KAU0a0OQd1yebE swP0yNi6XZK48Si8cQ9EZPVVXP4rVacPYGXURXs9fECmk6nWUrAiiQ1dd6tIXumW lpFj6Vxmg1OZpAL6UNlkUF4/K+2rsywq89/cc14ZjnYcb+6UvuPPe1fCDO/9fHzw hdc/8D3W8GRNYPySRS6owl45GDXiPSkUQbCfmFQtAPPO8cAS504z155dCyWxTAei 5Bl06tJPBLt5A0pi7cSOi9VvHbLkIt0eGTuMXDF7VPXUPU/9iLe0EbAUGflwzWSX ha1lRWvEEH4NvWdQoSKLhcLnB4CO/5vZiZk7OHLR06a1f6ayVGxCYu1mW9xGD3Ro WuAD6uNapNrFYDHpsF5fYjXB3A473GtRmZX8ERhdPhnD/XR2xqF2s8lc2kFMevyh EUCtwywoZG9AC53SGi9XckmG44yTABt7qXLIu43cy4N8R9d5gJpCksrB4eGB8HrG k7tQgLZcmIWrCLOsLN5WJEhiyJWmvC96iSaa0HYB02n1Szyi3KuL00L3+WlWhGMC nrbsc+lpWPYP1xXjtHs9TrUO+BB04B+htLZ653nYAg2qpD0YCi4= =w0N5 -----END PGP SIGNATURE-----
7 files changed,
152 insertions(+),
7 deletions(-)
M
README.md
→
README.md
@@ -33,7 +33,7 @@ ### auth
* [x] top-level wrapper for attaching `UserStore` backends to cookie handler * [x] POC [indental](https://wiki.xxiivv.com/site/indental.html) `UserStore` implementation -* [ ] Bearer token-based authentication to supplement cookie-baesd auth +* [x] both cookie- and token-based authentication (use one but not both together) ### etc@@ -43,6 +43,8 @@ - [x] `Authorize`: login and redirect
- [x] `Bunt`: logout and redirect - [x] `Fortify`: setup CSRF protection (use on the form) - [x] `Defend`: enact CSRF protection (use on the endpoint) + - [x] `Provision`: use BASIC authentication to provision an access token + - [x] `Validate`: valiate the bearer token against the `UserStore` ## license
M
auth/auth.go
→
auth/auth.go
@@ -27,6 +27,9 @@ GetLastLoginTime(user string) (time.Time, error)
GetLastTimeSeen(user string) (time.Time, error) SetData(user string, key string, value interface{}) error GetData(user string, key string) (interface{}, error) + GrantToken(user, password, scope string, minutes int) (string, error) + ValidateToken(token string) (bool, error) + ValidateTokenWithScopes(token string, scopes map[string]string) (bool, error) } func Login(user string, password string, userStore UserStore, w http.ResponseWriter, t int) error {
M
indentalUserDB/indentalUserDB.go
→
indentalUserDB/indentalUserDB.go
@@ -1,6 +1,7 @@
package indentalUserDB import ( + "encoding/base64" "errors" "fmt" "golang.org/x/crypto/bcrypt"@@ -50,6 +51,28 @@ writeDB(self.Basis, self.Users)
return sessionId, nil } +func (self *IndentalUserDB) GrantToken(user, password, scope string, minutes int) (string, error) { + if _, exists := self.Users[user]; !exists { + return "", errors.New("User not in DB") + } + if bcrypt.CompareHashAndPassword([]byte(self.Users[user].Pass), []byte(password)) != nil { + return "", errors.New("Incorrect password") + } + + s, err := self.GetData(user, "scope") + if err == nil && s == scope { + sessionId := cookie.GenToken(64) + self.Users[user].Session = sessionId + self.Users[user].LoginTime = time.Now() + self.Users[user].LastSeen = time.Now() + self.SetData(user, "token_expiry", time.Now().Add(time.Minute*time.Duration(minutes)).Format(timeFmt)) + writeDB(self.Basis, self.Users) + return base64.StdEncoding.EncodeToString([]byte(user + "\n" + sessionId)), nil + } + + return "", errors.New("Incorrect scope for this user") +} + func (self *IndentalUserDB) ValidateUser(user string, sessionId string) (bool, error) { if _, exists := self.Users[user]; !exists { return false, errors.New("User not in DB")@@ -62,6 +85,49 @@ writeDB(self.Basis, self.Users)
} return validated, nil +} + +func (self *IndentalUserDB) ValidateToken(token string) (bool, error) { + data, err := base64.StdEncoding.DecodeString(token) + if err == nil { + parts := strings.Split(string(data), "\n") + if len(parts) == 2 { + expiry, err3 := self.GetData(parts[0], "token_expiry") + expiryTime, err4 := time.Parse(timeFmt, expiry.(string)) + if err3 == nil && err4 == nil && time.Now().After(expiryTime) { + self.EndSession(parts[0]) + return false, errors.New("token has expired") + } else { + return self.ValidateUser(parts[0], parts[1]) + } + } + } + return false, errors.New("Token was not in a valid format: b64(USER\nSESSION)") +} + +func (self *IndentalUserDB) ValidateTokenWithScopes(token string, scopes map[string]string) (bool, error) { + data, err := base64.StdEncoding.DecodeString(token) + if err == nil { + parts := strings.Split(string(data), "\n") + n := 0 + for k, v := range scopes { + s, _ := self.GetData(parts[0], k) + if s.(string) == v { + n++ + } + } + validated, err2 := self.ValidateToken(token) + if validated { + if n == len(scopes) { + return validated, nil + } else { + return false, errors.New("User does not have the proper scopes") + } + } else { + return validated, err2 + } + } + return false, err } func (self *IndentalUserDB) EndSession(user string) error {
M
middleware/middleware.go
→
middleware/middleware.go
@@ -6,8 +6,17 @@ "fmt"
"net/http" "nilfm.cc/git/quartzgun/auth" "nilfm.cc/git/quartzgun/cookie" + "nilfm.cc/git/quartzgun/renderer" + "nilfm.cc/git/quartzgun/util" + "strings" ) +type TokenPayload struct { + access_token string + token_type string + expires_in int +} + func Protected(next http.Handler, method string, userStore auth.UserStore, login string) http.Handler { handlerFunc := func(w http.ResponseWriter, req *http.Request) { user, err := cookie.GetToken("user", req)@@ -16,8 +25,7 @@ session, err := cookie.GetToken("session", req)
if err == nil { login, err := userStore.ValidateUser(user, session) if err == nil && login { - fmt.Printf("authorized!\n") - fmt.Printf("user: %s, session: %s\n", user, session) + fmt.Printf("authorized user: %s\n", user) req.Method = method next.ServeHTTP(w, req) return@@ -43,7 +51,6 @@ w)
if err == nil { req.Method = http.MethodGet http.Redirect(w, req, next, http.StatusSeeOther) - return } } req.Method = http.MethodGet@@ -70,6 +77,49 @@ fmt.Printf("login failed!\n")
req.Method = http.MethodGet http.Redirect(w, req, denied, http.StatusSeeOther) } + } + + return http.HandlerFunc(handlerFunc) +} + +func Provision(userStore auth.UserStore, minutes int) http.Handler { + handlerFunc := func(w http.ResponseWriter, req *http.Request) { + user, password, ok := req.BasicAuth() + scope := req.FormValue("scope") + if ok && scope != "" { + token, err := userStore.GrantToken(user, password, scope, minutes) + if err == nil { + token := TokenPayload{ + access_token: token, + token_type: "bearer", + expires_in: minutes, + } + util.AddContextValue(req, "token", token) + renderer.JSON("token").ServeHTTP(w, req) + return + } + } + w.Header().Add("WWW-Authenticate", "Basic") + w.WriteHeader(http.StatusUnauthorized) + return + } + + return http.HandlerFunc(handlerFunc) +} + +func Validate(next http.Handler, userStore auth.UserStore, scopes map[string]string) http.Handler { + handlerFunc := func(w http.ResponseWriter, req *http.Request) { + authHeader := req.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + authToken := strings.Split(authHeader, "Bearer ")[1] + validated, err := userStore.ValidateTokenWithScopes(authToken, scopes) + if validated && err == nil { + next.ServeHTTP(w, req) + return + } + } + w.Header().Add("WWW-Authenticate", "Basic") + w.WriteHeader(http.StatusUnauthorized) } return http.HandlerFunc(handlerFunc)
M
renderer/renderer.go
→
renderer/renderer.go
@@ -21,6 +21,10 @@
return http.HandlerFunc(handlerFunc) } +func Subtree(path string) http.Handler { + return http.FileServer(http.Dir(path)) +} + func JSON(key string) http.Handler { handlerFunc := func(w http.ResponseWriter, req *http.Request) { apiData := req.Context().Value(key)
M
router/router.go
→
router/router.go
@@ -85,11 +85,21 @@ p = path.Clean(p)
/* If the file exists, try to serve it. */ info, err := os.Stat(p) - if err == nil && !info.IsDir() { - http.ServeFile(w, req, p) + if err == nil { + if !info.IsDir() { + http.ServeFile(w, req, p) + } else { + indexFile := path.Join(p, "index.html") + info2, err2 := os.Stat(indexFile) + if err2 == nil && !info2.IsDir() { + http.ServeFile(w, req, indexFile) + } else { + self.ErrorPage(w, req, 403, "Access forbidden") + } + } /* Handle the common errors */ } else if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrExist) { - self.ErrorPage(w, req, 404, "The requested file does not exist") + self.ErrorPage(w, req, 404, "The page you requested does not exist!") } else if errors.Is(err, os.ErrPermission) || info.IsDir() { self.ErrorPage(w, req, 403, "Access forbidden") /* If it's some weird error, serve a 500. */
A
util/util.go
@@ -0,0 +1,10 @@
+package util + +import ( + "context" + "net/http" +) + +func AddContextValue(req *http.Request, key string, value interface{}) { + *req = *req.WithContext(context.WithValue(req.Context(), key, value)) +}