// Copyright (c) 2020, Andreas Schneider // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are met: // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above copyright // notice, this list of conditions and the following disclaimer in the // documentation and/or other materials provided with the distribution. // * Neither the name of the nor the // names of its contributors may be used to endorse or promote products // derived from this software without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND // ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED // WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE // DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY // DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES // (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; // LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND // ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS // SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. package main import ( "context" "fmt" "html/template" "log" "net/http" "os" "path" "strings" "time" "github.com/go-chi/chi" passwordvalidator "github.com/lane-c-wagner/go-password-validator" uuid "github.com/satori/go.uuid" "github.com/tidwall/buntdb" "golang.org/x/crypto/bcrypt" ) type webAdminHandler struct { router chi.Router shareStore ShareStore tplError *template.Template tplConfirm *template.Template tplLogin *template.Template tplIndex *template.Template tplUsers *template.Template tplShares *template.Template tplMyShares *template.Template tplShareAddUser *template.Template tplShareAddLogin *template.Template tplCreateShare *template.Template tplCreateUser *template.Template tplChangePassword *template.Template } type sessionContext struct { h *webAdminHandler w http.ResponseWriter r *http.Request user User baseModel map[string]interface{} } const sessionCookieName = "sharedavsession" const sessionLifetime = 1 * time.Hour const confirmRequested = 0 const confirmAccepted = 1 const confirmDenied = 2 const minPasswordEntropy = 60 func newWebAdminHandler(app *app) *webAdminHandler { sessionStore, err := buntdb.Open(":memory:") if err != nil { panic("cannot initialize session store: " + err.Error()) } loadTemplate := func(filename string) *template.Template { t, err := template.ParseFiles( path.Join("templates", "base.html"), path.Join("templates", filename+".html"), ) if err != nil { panic(fmt.Sprintf("cannot load template %q: %v", filename, err)) } return t } loadSingleTemplate := func(filename string) *template.Template { t, err := template.ParseFiles(path.Join("templates", filename+".html")) if err != nil { panic(fmt.Sprintf("cannot load template %q: %v", filename, err)) } return t } h := &webAdminHandler{ shareStore: app.shareStore, tplError: loadTemplate("error"), tplConfirm: loadTemplate("confirm"), tplLogin: loadSingleTemplate("login"), tplIndex: loadTemplate("index"), tplUsers: loadTemplate("users"), tplShares: loadTemplate("shares"), tplMyShares: loadTemplate("my-shares"), tplShareAddUser: loadTemplate("share-add-user"), tplShareAddLogin: loadTemplate("share-add-login"), tplCreateShare: loadTemplate("create-share"), tplCreateUser: loadTemplate("create-user"), tplChangePassword: loadTemplate("change-password"), } r := chi.NewRouter() r.Use(disableCaching) r.Route("/login", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) sessionContext.RenderPage(h.tplLogin, nil) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) username := r.FormValue("username") if strings.ContainsAny(username, "*:") { sessionContext.RenderError("Username or password wrong.", "login") return } user, err := app.userStore.GetUser(username) if err != nil { sessionContext.RenderError("Username or password wrong.", "login") return } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(r.FormValue("password"))); err != nil { sessionContext.RenderError("Username or password wrong.", "login") return } sessionId := uuid.NewV4() if err := sessionStore.Update(func(tx *buntdb.Tx) error { _, _, err := tx.Set(sessionId.String(), user.Username, &buntdb.SetOptions{ Expires: true, TTL: sessionLifetime, }) return err }); err != nil { log.Printf("error setting session: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } cookie := &http.Cookie{ Name: sessionCookieName, Value: sessionId.String(), Expires: time.Now().Add(sessionLifetime), HttpOnly: true, } http.SetCookie(w, cookie) sessionContext.Redirect("./") }) }) ar := r.With(authenticated(sessionStore, app.userStore)) ar.Get("/logout", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) sessionCookie, err := r.Cookie(sessionCookieName) if err != nil { http.Redirect(w, r, "login", http.StatusFound) return } if err := sessionStore.Update(func(tx *buntdb.Tx) error { _, err := tx.Delete(sessionCookie.Value) return err }); err != nil { log.Printf("error unsetting session: %v\n", err) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } sessionContext.Redirect("./") }) ar.Get("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) sessionContext.RenderPage(h.tplIndex, nil) }) ar.Get("/users", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) if sessionContext.user.Role != GlobalRoleAdmin { sessionContext.Unauthorized() return } users, err := app.userStore.GetUsers() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sessionContext.RenderPage(h.tplUsers, map[string]interface{}{ "Users": users, }) }) ar.Get("/shares", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) if sessionContext.user.Role != GlobalRoleAdmin { sessionContext.Unauthorized() return } shares, err := app.shareStore.GetShares() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type shareInfo struct { Share Users []ShareUser } shareInfos := make([]shareInfo, len(shares)) for i := range shares { shareInfos[i].Share = shares[i] users, err := app.shareStore.GetShareUsers(shares[i]) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } shareInfos[i].Users = users } sessionContext.RenderPage(h.tplShares, map[string]interface{}{ "ShareInfos": shareInfos, }) }) ar.Get("/my-shares", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) shares, err := app.shareStore.FindSharesByUser(sessionContext.user.Username) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } type shareInfo struct { Share IsAdmin bool Logins []Login Users []ShareUser } shareInfos := make([]shareInfo, len(shares)) for i := range shares { shareInfos[i].Share = shares[i].Share shareRole, err := app.shareStore.GetShareAccess(shares[i].Share, sessionContext.user.Username) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } shareInfos[i].IsAdmin = sessionContext.user.Role == GlobalRoleAdmin || shareRole == ShareRoleAdmin if shareInfos[i].IsAdmin { users, err := app.shareStore.GetShareUsers(shares[i].Share) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } shareInfos[i].Users = users } logins, err := app.shareStore.GetShareLogins(shares[i].Share, sessionContext.user.Username) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } shareInfos[i].Logins = logins } sessionContext.RenderPage(h.tplMyShares, map[string]interface{}{ "ShareInfos": shareInfos, }) }) ar.Route("/share-add-user", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) share, err := app.shareStore.GetShare(r.URL.Query().Get("share")) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } if !sessionContext.IsAdmin(share) { sessionContext.Unauthorized() return } users, err := app.userStore.GetUsers() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sessionContext.RenderPage(h.tplShareAddUser, map[string]interface{}{ "ShareId": share.UUID, "Users": users, }) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) returnURL := "share-add-user?share=" + r.FormValue("share") share, err := app.shareStore.GetShare(r.FormValue("share")) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } if !sessionContext.IsAdmin(share) { sessionContext.Unauthorized() return } user, err := app.userStore.GetUser(r.FormValue("user")) if err == ErrUserNotFound { sessionContext.RenderError("User not found.", returnURL) return } else if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } err = app.shareStore.AddUserToShare(share, user.Username, ShareRole(r.FormValue("role"))) if err != nil { sessionContext.RenderError(template.HTML("Cannot add user to share: "+err.Error()), returnURL) return } sessionContext.Redirect("shares") }) }) ar.Post("/share-delete-user", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) returnURL := r.FormValue("source") share, err := app.shareStore.GetShare(r.FormValue("share")) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } if !sessionContext.IsAdmin(share) { sessionContext.Unauthorized() return } username := r.FormValue("user") message := fmt.Sprintf(`You are about to delete the user "%s" from the share %s.
This will irrevocably also remove all logins of that user.

Are you sure you want to continue?`, username, share.Name) if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "share-delete-user"); confirmStatus == confirmRequested { // We have already rendered. Nothing to do. return } else if confirmStatus == confirmAccepted { err = app.shareStore.RemoveUserFromShare(share, username) if err != nil { sessionContext.RenderError(template.HTML("Cannot remove user from share: "+err.Error()), returnURL) return } } http.Redirect(w, r, returnURL, http.StatusFound) }) ar.Route("/share-add-login", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) share, err := app.shareStore.GetShare(r.URL.Query().Get("share")) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } if !sessionContext.IsAdmin(share) { sessionContext.Unauthorized() return } sessionContext.RenderPage(h.tplShareAddLogin, map[string]interface{}{ "ShareId": share.UUID, }) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) returnURL := "share-add-login?share=" + r.FormValue("share") share, err := app.shareStore.GetShare(r.FormValue("share")) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), returnURL) return } if !sessionContext.IsAdmin(share) { sessionContext.Unauthorized() return } pwHash, err := bcrypt.GenerateFromPassword([]byte(r.FormValue("password")), bcrypt.DefaultCost) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), returnURL) return } err = app.shareStore.AddLogin(share, sessionContext.user.Username, Login{ LoginName: r.FormValue("login"), Password: string(pwHash), ReadOnly: r.FormValue("readonly") == "on", }) if err != nil { sessionContext.RenderError(template.HTML("Cannot add login: "+err.Error()), returnURL) return } sessionContext.Redirect("my-shares") }) }) ar.Post("/share-delete-login", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) returnURL := "shares" share, err := app.shareStore.GetShare(r.FormValue("share")) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } if !sessionContext.IsAdmin(share) { sessionContext.Unauthorized() return } loginName := r.FormValue("login") message := fmt.Sprintf(`You are about to delete the login "%s".

Are you sure you want to continue?`, loginName) if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "share-delete-login"); confirmStatus == confirmRequested { // We have already rendered. Nothing to do. return } else if confirmStatus == confirmAccepted { err = app.shareStore.RemoveLogin(share, sessionContext.user.Username, loginName) if err != nil { sessionContext.RenderError(template.HTML("Cannot remove login from share: "+err.Error()), returnURL) return } } http.Redirect(w, r, "my-shares", http.StatusFound) }) ar.Route("/create-share", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) owned := r.FormValue("owned") == "true" if !owned && sessionContext.user.Role != GlobalRoleAdmin { sessionContext.Unauthorized() return } sessionContext.RenderPage(h.tplCreateShare, nil) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) owned := r.FormValue("owned") == "true" if !owned && sessionContext.user.Role != GlobalRoleAdmin { sessionContext.Unauthorized() return } share, err := app.shareStore.CreateShare() if err != nil { sessionContext.RenderError(template.HTML("Cannot create share: "+err.Error()), "") return } share.Name = r.FormValue("name") share.Description = r.FormValue("description") if err := app.shareStore.UpdateShareAttributes(share); err != nil { sessionContext.RenderError(template.HTML("Cannot update share: "+err.Error()), "") return } if err := os.MkdirAll(path.Join(app.DataDirectory, share.UUID.String()), 0750); err != nil { // Best effort cleanup. _ = app.shareStore.RemoveShare(share.UUID) fmt.Fprintf(os.Stderr, "cannot create data dir: %v\n", err) sessionContext.RenderError(template.HTML("Internal server error."), "") return } if owned { if err := app.shareStore.AddUserToShare(share, sessionContext.user.Username, ShareRoleAdmin); err != nil { sessionContext.RenderError(template.HTML("Cannot add self to share: "+err.Error()), "") return } sessionContext.Redirect("my-shares#share-" + share.UUID.String()) } else { sessionContext.Redirect("shares#share-" + share.UUID.String()) } }) }) ar.Post("/delete-share", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) returnURL := r.FormValue("source") share, err := app.shareStore.GetShare(r.FormValue("share")) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), returnURL) return } if !sessionContext.IsAdmin(share) { sessionContext.Unauthorized() return } message := fmt.Sprintf(`You are about to delete the share %s (%s).
This will delete all data permanently.

Are you sure you want to continue?`, share.UUID, share.Name) if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "delete-share"); confirmStatus == confirmRequested { // We have already rendered. Nothing to do. return } else if confirmStatus == confirmAccepted { if err := os.RemoveAll(path.Join(app.DataDirectory, share.UUID.String())); err != nil { fmt.Fprintf(os.Stderr, "cannot remove data dir: %v\n", err) sessionContext.RenderError(template.HTML("Internal server error."), returnURL) return } if err := app.shareStore.RemoveShare(share.UUID); err != nil { sessionContext.RenderError(template.HTML("Share cannot be removed: "+err.Error()), returnURL) return } } sessionContext.Redirect(returnURL) }) ar.Route("/create-user", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) if sessionContext.user.Role != GlobalRoleAdmin { sessionContext.Unauthorized() return } sessionContext.RenderPage(h.tplCreateUser, nil) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) if sessionContext.user.Role != GlobalRoleAdmin { sessionContext.Unauthorized() return } returnURL := "users" pwHash, err := bcrypt.GenerateFromPassword([]byte(r.FormValue("password")), bcrypt.DefaultCost) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), returnURL) return } user := User{ Username: r.FormValue("username"), Password: string(pwHash), Role: GlobalRole(r.FormValue("role")), } switch user.Role { case GlobalRoleUser: case GlobalRoleAdmin: default: sessionContext.RenderError(template.HTML("Invalid role."), "") return } err = app.userStore.AddUser(user) if err != nil { sessionContext.RenderError(template.HTML("Cannot create user: "+err.Error()), "") return } sessionContext.Redirect("users#user-" + user.Username) }) }) ar.Post("/delete-user", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) if sessionContext.user.Role != GlobalRoleAdmin { sessionContext.Unauthorized() return } user, err := app.userStore.GetUser(r.FormValue("user")) if err != nil { sessionContext.RenderError(template.HTML("Cannot delete user: "+err.Error()), "users") return } message := fmt.Sprintf(`You are about to delete the user %s (%s).
This will delete all share associations and logins.

Are you sure you want to continue?`, user.Username, user.Role) if confirmStatus := sessionContext.RequestConfirmation(template.HTML(message), "delete-user"); confirmStatus == confirmRequested { // We have already rendered. Nothing to do. return } else if confirmStatus == confirmAccepted { cmd := CmdUserDelete{Username: user.Username} if err := cmd.Run(app); err != nil { sessionContext.RenderError(template.HTML("Cannot delete user: "+err.Error()), "users") return } } invalidateSession(sessionStore, user.Username) sessionContext.Redirect("users") }) ar.Route("/change-password", func(r chi.Router) { r.Get("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) sessionContext.RenderPage(h.tplChangePassword, nil) }) r.Post("/", func(w http.ResponseWriter, r *http.Request) { sessionContext := h.buildSessionContext(w, r) currentPassword := r.FormValue("password") newPassword := r.FormValue("password-new") repeatPassword := r.FormValue("password-repeat") if err := bcrypt.CompareHashAndPassword([]byte(sessionContext.user.Password), []byte(currentPassword)); err != nil { sessionContext.RenderError(template.HTML("The current password is wrong. Please check."), "") return } if newPassword != repeatPassword { sessionContext.RenderError(template.HTML("The new password doesn't match the repeated password."), "") return } if err := passwordvalidator.Validate(newPassword, minPasswordEntropy); err != nil { sessionContext.RenderError(template.HTML("The new password is not strong enough."), "") return } pwHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } if err := app.userStore.UpdateUser(User{ Username: sessionContext.user.Username, Password: string(pwHash), }); err != nil { sessionContext.RenderError(template.HTML("Internal error: "+err.Error()), "") return } invalidateSession(sessionStore, sessionContext.user.Username) sessionContext.Redirect("./") }) }) h.router = r return h } func (h *webAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.router.ServeHTTP(w, r) } func (h *webAdminHandler) buildSessionContext(w http.ResponseWriter, r *http.Request) *sessionContext { sessionContext := &sessionContext{ h: h, w: w, r: r, baseModel: map[string]interface{}{}, } sessionUser := userFromContext(r) if sessionUser != nil { sessionContext.user = *sessionUser sessionContext.baseModel["SessionUser"] = sessionUser } return sessionContext } func (s *sessionContext) Redirect(target string) { http.Redirect(s.w, s.r, target, http.StatusFound) } func (s *sessionContext) RenderPage(tmpl *template.Template, model map[string]interface{}) { effectiveModel := map[string]interface{}{} for k, v := range s.baseModel { effectiveModel[k] = v } for k, v := range model { effectiveModel[k] = v } if err := tmpl.Execute(s.w, effectiveModel); err != nil { http.Error(s.w, err.Error(), http.StatusInternalServerError) } } func (s *sessionContext) RenderError(msg template.HTML, returnURL string) { model := map[string]interface{}{ "ErrorMessage": msg, "ReturnURL": returnURL, } s.RenderPage(s.h.tplError, model) } func (s *sessionContext) IsAdmin(share Share) bool { if s.user.Role != GlobalRoleAdmin { shareRole, err := s.h.shareStore.GetShareAccess(share, s.user.Username) if err != nil || shareRole != ShareRoleAdmin { return false } } return true } func (s *sessionContext) Unauthorized() { http.Error(s.w, http.StatusText(http.StatusForbidden), http.StatusForbidden) } func (s *sessionContext) RequestConfirmation(msg template.HTML, returnURL string) int { if s.r.FormValue("_yes") != "" { return confirmAccepted } else if s.r.FormValue("_no") != "" { return confirmDenied } else { fields := map[string]string{} for k, v := range s.r.Form { fields[k] = v[0] } model := map[string]interface{}{ "URL": returnURL, "Message": msg, "Fields": fields, } s.RenderPage(s.h.tplConfirm, model) return confirmRequested } } func disableCaching(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") next.ServeHTTP(w, r) }) } func authenticated(sessionStore *buntdb.DB, userStore UserStore) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionCookie, err := r.Cookie(sessionCookieName) if err != nil { http.Redirect(w, r, "login", http.StatusFound) return } var username string if err := sessionStore.View(func(tx *buntdb.Tx) error { val, err := tx.Get(sessionCookie.Value) if err != nil { return err } username = val return nil }); err != nil { http.Redirect(w, r, "login", http.StatusFound) return } user, err := userStore.GetUser(username) if err != nil { http.Redirect(w, r, "login", http.StatusFound) return } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "user", &user))) }) } } func userFromContext(r *http.Request) *User { if user, ok := r.Context().Value("user").(*User); ok { return user } else { return nil } } func invalidateSession(store *buntdb.DB, username string) { err := store.Update(func(tx *buntdb.Tx) error { var sessionIds []string if err := tx.AscendKeys("*", func(key, value string) bool { if value == username { sessionIds = append(sessionIds, key) } return true }); err != nil { return err } for _, sessionId := range sessionIds { if _, err := tx.Delete(sessionId); err != nil { fmt.Fprintf(os.Stderr, "cannot remove session: %v\n", err) } } return nil }) if err != nil { fmt.Fprintf(os.Stderr, "cannot invalidate session: %v\n", err) } }