diff --git a/README.md b/README.md index 9732837..5b20f30 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Features may be added here at any time as things are in early stages right now: * [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 @@ Features may be added here at any time as things are in early stages right now: - [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 diff --git a/auth/auth.go b/auth/auth.go index c7a21e1..6b79b04 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -27,6 +27,9 @@ type UserStore interface { 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 { diff --git a/indentalUserDB/indentalUserDB.go b/indentalUserDB/indentalUserDB.go index cdf73e9..fda255d 100644 --- a/indentalUserDB/indentalUserDB.go +++ b/indentalUserDB/indentalUserDB.go @@ -1,6 +1,7 @@ package indentalUserDB import ( + "encoding/base64" "errors" "fmt" "golang.org/x/crypto/bcrypt" @@ -50,6 +51,28 @@ func (self *IndentalUserDB) InitiateSession(user string, password string) (strin 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") @@ -64,6 +87,49 @@ func (self *IndentalUserDB) ValidateUser(user string, sessionId string) (bool, e 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 { if _, exists := self.Users[user]; !exists { return errors.New("User not in DB") diff --git a/middleware/middleware.go b/middleware/middleware.go index d83f4b1..67b38a1 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -6,8 +6,17 @@ import ( "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 @@ func Protected(next http.Handler, method string, userStore auth.UserStore, login 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 @@ func Bunt(next string, userStore auth.UserStore, denied string) http.Handler { if err == nil { req.Method = http.MethodGet http.Redirect(w, req, next, http.StatusSeeOther) - return } } req.Method = http.MethodGet @@ -75,6 +82,49 @@ func Authorize(next string, userStore auth.UserStore, denied string) http.Handle 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) +} + func Fortify(next http.Handler) http.Handler { handlerFunc := func(w http.ResponseWriter, req *http.Request) { token, err := cookie.GetToken("csrfToken", req) diff --git a/renderer/renderer.go b/renderer/renderer.go index 3d912eb..dd1e6d4 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -21,6 +21,10 @@ func Template(t ...string) http.Handler { 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) diff --git a/router/router.go b/router/router.go index f80b8f6..3278771 100644 --- a/router/router.go +++ b/router/router.go @@ -85,11 +85,21 @@ func (self *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { /* 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. */ diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..ba2f5c3 --- /dev/null +++ b/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)) +}