all repos — quartzgun @ 48dbb967f38ea4af6692e38c1676057315e06b2b

lightweight web framework in go

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-----
commit

48dbb967f38ea4af6692e38c1676057315e06b2b

parent

c8e51492376bc02133da4dd644d67e81546b5591

M README.mdREADME.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.goauth/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.goindentalUserDB/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.gomiddleware/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.gorenderer/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.gorouter/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)) +}