From b4793cbab5ce6c14a3204aac21c11277c6bd7a11 Mon Sep 17 00:00:00 2001 From: Knut Ahlers Date: Sun, 4 Nov 2018 11:50:03 +0100 Subject: [PATCH] Reimplement Go server as API-Server Signed-off-by: Knut Ahlers --- ajax.go | 107 --------------- api.go | 115 +++++++++++++++++ api_funcs.go | 343 +++++++++++++++++++++++++++++++++++++++++++++++++ dataObject.go | 65 +++++++++- gzip.go | 46 +++++++ httpHelper.go | 64 --------- login.go | 96 -------------- overview.go | 32 ----- register.go | 50 ------- router.go | 45 +++---- sessionData.go | 17 +++ storage.go | 1 + 12 files changed, 597 insertions(+), 384 deletions(-) delete mode 100644 ajax.go create mode 100644 api.go create mode 100644 api_funcs.go create mode 100644 gzip.go delete mode 100644 httpHelper.go delete mode 100644 login.go delete mode 100644 overview.go delete mode 100644 register.go create mode 100644 sessionData.go diff --git a/ajax.go b/ajax.go deleted file mode 100644 index fbd8159..0000000 --- a/ajax.go +++ /dev/null @@ -1,107 +0,0 @@ -package main - -import ( - "context" - "crypto/sha1" - "encoding/json" - "fmt" - "net/http" - - "github.com/flosch/pongo2" - "github.com/gorilla/sessions" - log "github.com/sirupsen/logrus" -) - -type ajaxResponse struct { - Error bool `json:"error"` - Version string `json:"version"` - Data string `json:"data"` - Type string `json:"type"` -} - -func (a ajaxResponse) Bytes() []byte { - out, _ := json.Marshal(a) - return out -} - -func ajaxGetHandler(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) { - res.Header().Set("Content-Type", "application/json") - user, _ := checkLogin(c, r, session) - - if user == nil || !storage.IsPresent(c, user.UserFile) { - res.Write(ajaxResponse{Error: true}.Bytes()) - return nil, nil - } - - userFileRaw, err := storage.Read(c, user.UserFile) - if err != nil { - log.WithError(err).Error("Could not read user file from storage") - res.Write(ajaxResponse{Error: true}.Bytes()) - return nil, nil - } - - userFile, _ := readDataObject(userFileRaw) - - res.Write(ajaxResponse{Version: userFile.MetaData.Version, Data: userFile.Data}.Bytes()) - return nil, nil -} - -func ajaxPostHandler(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) { - res.Header().Set("Content-Type", "application/json") - user, _ := checkLogin(c, r, session) - - if user == nil { - res.Write(ajaxResponse{Error: true, Type: "login"}.Bytes()) - return nil, nil - } - - if !storage.IsPresent(c, user.UserFile) { - res.Write(ajaxResponse{Error: true, Type: "register"}.Bytes()) - return nil, nil - } - - userFileRaw, err := storage.Read(c, user.UserFile) - if err != nil { - log.WithError(err).Error("Could not read user file from storage") - res.Write(ajaxResponse{Error: true, Type: "storage_error"}.Bytes()) - return nil, nil - } - - userFile, _ := readDataObject(userFileRaw) - - var ( - version = r.FormValue("version") - checksum = r.FormValue("checksum") - data = r.FormValue("data") - ) - - if userFile.MetaData.Version != version { - res.Write(ajaxResponse{Error: true, Type: "version"}.Bytes()) - return nil, nil - } - - if checksum != fmt.Sprintf("%x", sha1.Sum([]byte(data))) { - res.Write(ajaxResponse{Error: true, Type: "checksum"}.Bytes()) - return nil, nil - } - - if err := storage.Backup(c, user.UserFile); err != nil { - log.WithError(err).Error("Could not create backup of user file") - res.Write(ajaxResponse{Error: true, Type: "storage_error"}.Bytes()) - return nil, nil - } - - userFile.MetaData.Version = checksum - userFile.Data = data - - d, _ := userFile.GetData() - - if err := storage.Write(c, user.UserFile, d); err != nil { - log.WithError(err).Error("Could not write user file to storage") - res.Write(ajaxResponse{Error: true, Type: "storage_error"}.Bytes()) - return nil, nil - } - - res.Write(ajaxResponse{Version: userFile.MetaData.Version, Data: userFile.Data}.Bytes()) - return nil, nil -} diff --git a/api.go b/api.go new file mode 100644 index 0000000..6269480 --- /dev/null +++ b/api.go @@ -0,0 +1,115 @@ +package main + +import ( + "encoding/json" + "net/http" + + "github.com/gofrs/uuid" + log "github.com/sirupsen/logrus" +) + +type userState string + +const ( + userStateLoggedin userState = "logged-in" + userStateRequireMFA = "require-mfa" +) + +type apiError struct { + cause error + msg string +} + +func (a apiError) Error() string { return a.msg + ": " + a.cause.Error() } +func (a apiError) Cause() error { return a.cause } +func (a apiError) UserMessage() string { return a.msg } +func wrapAPIError(cause error, msg string) error { + if cause == nil { + return nil + } + return apiError{cause, msg} +} + +func apiHelper(hdl apiHandler) http.HandlerFunc { + return func(res http.ResponseWriter, r *http.Request) { + cookieSession, err := cookieStore.Get(r, "cloudkeys-go") + if err != nil { + log.WithError(err).Debug("Session could not be decoded, created new one") + } + + sess := newSessionData() + if !cookieSession.IsNew { + if err := json.Unmarshal(cookieSession.Values["sessionData"].([]byte), sess); err != nil { + log.WithError(err).Debug("Session cookie contained garbled sessionData") + // Session data is garbled, create a new session in case + // something was decoded into the session object + sess = newSessionData() + } + } + + // This is a pure JSON API + res.Header().Set("Content-Type", "application/json") + res.Header().Set("Cache-Control", "no-cache") + res.Header().Set("X-API-Version", version) + res.Header().Set("Access-Control-Allow-Origin", "*") // FIXME (kahlers): Remove after development + + // Assign an UUID to find potiential errors in the logs + reqId := uuid.Must(uuid.NewV4()).String() + + var ( + resp interface{} + status int + data []byte + ) + + // Define a common error handler + defer func() { + if err != nil { + log.WithFields(log.Fields{ + "reqId": reqId, + "method": r.Method, + "path": r.URL.Path, + }).WithError(err).Error("API handler errored") + + // Respond with common error format + res.WriteHeader(status) + json.NewEncoder(res).Encode(map[string]interface{}{ + "error": err.(apiError).UserMessage(), + "reqId": reqId, + "success": false, + }) + } + }() + + // Do the real work + resp, status, err = hdl(getContext(r), res, r, sess) + if err != nil { + return + } + + // If there was no error try to marshal the output and wrap error + // when this fails in order to have a propoer user message + data, err = json.Marshal(resp) + err = wrapAPIError(err, "Could not marshal API response") + if err != nil { + return + } + + // Write the session data back to the cookie + sdata, err := json.Marshal(sess) + err = wrapAPIError(err, "Failed to encode session") + if err != nil { + return + } + + cookieSession.Values["sessionData"] = sdata + err = wrapAPIError(cookieSession.Save(r, res), "Failed to save session") + if err != nil { + return + } + + // If no error ocurred, send the response + res.WriteHeader(status) + res.Write(data) + } +} diff --git a/api_funcs.go b/api_funcs.go new file mode 100644 index 0000000..38986d0 --- /dev/null +++ b/api_funcs.go @@ -0,0 +1,343 @@ +package main + +import ( + "context" + "crypto/sha1" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + + "golang.org/x/crypto/bcrypt" + + "github.com/gorilla/mux" + "github.com/pquerna/otp/totp" + + openssl "github.com/Luzifer/go-openssl" +) + +type apiHandler func(context.Context, http.ResponseWriter, *http.Request, *sessionData) (interface{}, int, error) + +// apiChangeLoginPassword accepts three passwords: old, new and +// new-repeat. It checks the old password, compares the new ones +// and if they match it sets a new user password. In case the user +// has a MFA secret it is re-encrypted with the new password. +func apiChangeLoginPassword(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + var ( + input = struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + RepeatPassword string `json:"repeat_password"` + }{} + output = map[string]interface{}{"success": true} + username = mux.Vars(r)["user"] + ) + + if err := json.NewDecoder(r.Body).Decode(input); err != nil { + return nil, http.StatusBadRequest, wrapAPIError(err, "Unable to decode login data") + } + + if state, ok := sess.Users[username]; !ok || state != userStateLoggedin { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("Access to user not logged in"), "Authorization error") + } + + // Check new passwords matches + if input.NewPassword != input.RepeatPassword { + return nil, http.StatusBadRequest, wrapAPIError(errors.New("Password mismatch"), "New passwords do not match") + } + + // Retrieve data file + userFile := createUserFilename(username) + user, err := dataObjectFromStorage(ctx, storage, userFile) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Unable to retrieve data file") + } + + // Check bcrypt password and deprecated version of password + deprecatedPassword := fmt.Sprintf("%x", sha1.Sum([]byte(cfg.PasswordSalt+input.OldPassword))) // Here for backwards compatibility + if bcrypt.CompareHashAndPassword([]byte(user.MetaData.Password), []byte(input.OldPassword)) != nil && + user.MetaData.Password != deprecatedPassword { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("Password mismatch"), "Authorization error") + } + + // Update user password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Unable to generate bcrypt hash") + } + user.MetaData.Password = string(hashedPassword) + + // In case a MFA token is present re-encrypt it with the new password + if user.MetaData.MFASecret != "" { + secret, err := openssl.New().DecryptBytes(input.OldPassword, []byte(user.MetaData.MFASecret), openssl.DigestSHA256Sum) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Could not decrypt MFA secret") + } + + secret, err = openssl.New().EncryptBytes(input.NewPassword, secret, openssl.DigestSHA256Sum) + user.MetaData.MFASecret = string(secret) + } + + // Save back the user file + if err := user.writeToStorage(ctx, storage, userFile); err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Could not write data file") + } + + return output, http.StatusOK, nil +} + +// apiGetUserData retrieves the user file and returns the encrypted +// data together with the current checksum of the content +func apiGetUserData(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + var username = mux.Vars(r)["user"] + + if state, ok := sess.Users[username]; !ok || state != userStateLoggedin { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("Access to user not logged in"), "Authorization error") + } + + // Retrieve data file + userFile := createUserFilename(username) + user, err := dataObjectFromStorage(ctx, storage, userFile) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Unable to retrieve data file") + } + + return map[string]interface{}{ + "checksum": user.MetaData.Version, + "data": user.Data, + }, http.StatusOK, nil +} + +func apiGetUserSettings(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + // FIXME (kahlers): Implement this + return nil, http.StatusInternalServerError, wrapAPIError(errors.New("Not implemented yet"), "Not implemented") +} + +// apiListUsers returns a dictionary of usernames with their current login state +func apiListUsers(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + return sess.Users, http.StatusOK, nil +} + +// apiLogin retrieves an username and a password, loads the user file +// from storage and compares the passwords. If the user has no MFA they +// are logged in, otherwise they require MFA auth. After login the user +// file is automatically migrated to the latest version. +func apiLogin(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + var ( + input = &struct { + Username string + Password string + }{} + output = map[string]interface{}{"success": true} + ) + + if err := json.NewDecoder(r.Body).Decode(input); err != nil { + return nil, http.StatusBadRequest, wrapAPIError(err, "Unable to decode login data") + } + + if _, ok := sess.Users[input.Username]; ok { + // Already logged in + return output, http.StatusOK, nil + } + + userFile := createUserFilename(input.Username) + if !storage.IsPresent(ctx, userFile) { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("Userfile not present"), "Authorization error") + } + + // Retrieve data file + user, err := dataObjectFromStorage(ctx, storage, userFile) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Unable to retrieve data file") + } + + // Check bcrypt password and deprecated version of password + deprecatedPassword := fmt.Sprintf("%x", sha1.Sum([]byte(cfg.PasswordSalt+input.Password))) // Here for backwards compatibility + if bcrypt.CompareHashAndPassword([]byte(user.MetaData.Password), []byte(input.Password)) != nil && + user.MetaData.Password != deprecatedPassword { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("Password mismatch"), "Authorization error") + } + + // Apply migrations to data file automatically + if err := user.migrate(ctx, storage, userFile, input.Password); err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Migrating the data file caused an error") + } + + // Set user as logged in + if user.MetaData.MFASecret == "" { + sess.Users[input.Username] = userStateLoggedin + } else { + secret, err := openssl.New().DecryptBytes(input.Password, []byte(user.MetaData.MFASecret), openssl.DigestSHA256Sum) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Could not decrypt MFA secret") + } + + sess.Users[input.Username] = userStateRequireMFA + sess.MFACache[input.Username] = string(secret) + } + + return output, http.StatusOK, nil +} + +// apiLogoutUser removes the given user from the list of logged in users +// and forgets the MFA secret in case it was still set. +func apiLogoutUser(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + var ( + output = map[string]interface{}{"success": true} + user = mux.Vars(r)["user"] + ) + + if _, ok := sess.MFACache[user]; ok { + delete(sess.MFACache, user) + } + + if _, ok := sess.Users[user]; ok { + delete(sess.Users, user) + } + + return output, http.StatusOK, nil +} + +// apiRegister takes an username and two passwords, compares the +// passwords, ensures the username is not already taken and creates +// a new empty user file. +func apiRegister(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + var ( + input = struct { + Username string `json:"username"` + Password string `json:"password"` + CheckPassword string `json:"check_password"` + }{} + output = map[string]interface{}{"success": true} + ) + + if err := json.NewDecoder(r.Body).Decode(input); err != nil { + return nil, http.StatusBadRequest, wrapAPIError(err, "Unable to decode request") + } + + // Check input + if input.Username == "" || input.Password == "" || input.Password != input.CheckPassword { + return nil, http.StatusBadRequest, wrapAPIError(errors.New("Invalid input data"), "Invalid input provided") + } + + // Check username collision + userFile := createUserFilename(input.Username) + if storage.IsPresent(ctx, userFile) { + return nil, http.StatusBadRequest, wrapAPIError(errors.New("User file found"), "Username is already taken") + } + + // Create user file + user := newDataObject() + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Unable to generate bcrypt hash") + } + user.MetaData.Password = string(hashedPassword) + + // Save the user file + if err := user.writeToStorage(ctx, storage, userFile); err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Could not write data file") + } + + return output, http.StatusOK, nil +} + +// apiSetUserData retrieves two checksums and an encrypted data blob. +// It compares the old checksum still matches to ensure no changes +// of another user is overwritten and it compares the received checksum +// of the new data blob to ensure the data integrity is fine. Afterwards +// the user file is backupped and updated. +func apiSetUserData(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + var ( + input = struct { + Checksum string `json:"checksum"` + OldChecksum string `json:"old_checksum"` + Data string `json:"data"` + }{} + output = map[string]interface{}{"success": true} + username = mux.Vars(r)["user"] + ) + + if err := json.NewDecoder(r.Body).Decode(input); err != nil { + return nil, http.StatusBadRequest, wrapAPIError(err, "Unable to decode request") + } + + if state, ok := sess.Users[username]; !ok || state != userStateLoggedin { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("Access to user not logged in"), "Authorization error") + } + + // Retrieve data file + userFile := createUserFilename(username) + user, err := dataObjectFromStorage(ctx, storage, userFile) + if err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Unable to retrieve data file") + } + + // Check the user is still updating the same version of the data + if user.MetaData.Version != input.OldChecksum { + return nil, http.StatusBadRequest, wrapAPIError(errors.New("Update on outdated data"), "Old data checksum does not match") + } + + // Check we've got the data the user intended to send + if input.Checksum != fmt.Sprintf("%x", sha256.Sum256([]byte(input.Data))) { + return nil, http.StatusBadRequest, wrapAPIError(errors.New("Checksum mismatch on input data"), "New data checksum does not match") + } + + // Create a backup because you know... + if err := storage.Backup(ctx, userFile); err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Could not create backup, nothin saved") + } + + user.MetaData.Version = input.Checksum + user.Data = input.Data + + if err := user.writeToStorage(ctx, storage, userFile); err != nil { + return nil, http.StatusInternalServerError, wrapAPIError(err, "Could not write data file") + } + + return output, http.StatusOK, nil +} + +func apiSetUserSettings(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + // FIXME (kahlers): Implement this + return nil, http.StatusInternalServerError, wrapAPIError(errors.New("Not implemented yet"), "Not implemented") +} + +// apiValidateMFA retrieves an OTP token and in case of a match with +// the token generated from the stored secret the user is logged in. +func apiValidateMFA(ctx context.Context, res http.ResponseWriter, r *http.Request, sess *sessionData) (interface{}, int, error) { + var ( + input = struct { + Token string `json:"token"` + }{} + output = map[string]interface{}{"success": true} + user = mux.Vars(r)["user"] + ) + + if err := json.NewDecoder(r.Body).Decode(input); err != nil { + return nil, http.StatusBadRequest, wrapAPIError(err, "Unable to decode login data") + } + + state, ok := sess.Users[user] + if ok && state != userStateLoggedin { + // Not requesting MFA authorization + return output, http.StatusOK, nil + } else if !ok { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("User not logged in"), "Authorization error") + } + + secret, ok := sess.MFACache[user] + if !ok { + return nil, http.StatusInternalServerError, wrapAPIError(errors.New("Missing OTP secret"), "Unable to find OTP secret") + } + if !totp.Validate(input.Token, secret) { + return nil, http.StatusUnauthorized, wrapAPIError(errors.New("OTP token mismatch"), "Invalid OTP token") + } + + // Remove secret from session and set user logged in + delete(sess.MFACache, "user") + sess.Users[user] = userStateLoggedin + + return output, http.StatusOK, nil +} diff --git a/dataObject.go b/dataObject.go index ffd8abe..a019464 100644 --- a/dataObject.go +++ b/dataObject.go @@ -2,11 +2,17 @@ package main import ( "bytes" + "context" "encoding/gob" "encoding/json" "io" + + "github.com/pkg/errors" + "golang.org/x/crypto/bcrypt" ) +const currentSchemaVersion = 1 + type authorizedAccounts []authorizedAccount type authorizedAccount struct { Name string @@ -18,19 +24,66 @@ func init() { } type dataObject struct { - MetaData struct { - Version string `json:"version"` - Password string `json:"password"` + SchemaVersion int `json:"schema_version"` + MetaData struct { + Version string `json:"version"` + Password string `json:"password"` + MFASecret string `json:"mfa_secret"` } `json:"metadata"` Data string `json:"data"` } -func readDataObject(in io.Reader) (*dataObject, error) { - t := &dataObject{} - return t, json.NewDecoder(in).Decode(t) +func newDataObject() *dataObject { + return &dataObject{ + SchemaVersion: currentSchemaVersion, + } } +func dataObjectFromStorage(ctx context.Context, storage storageAdapter, filename string) (*dataObject, error) { + userFileRaw, err := storage.Read(ctx, filename) + if err != nil { + return nil, errors.Wrap(err, "Unable to read data file from storage") + } + t := &dataObject{} + return t, json.NewDecoder(userFileRaw).Decode(t) +} + +// FIXME (kahlers): remove func (d *dataObject) GetData() (io.Reader, error) { buf := bytes.NewBuffer([]byte{}) return buf, json.NewEncoder(buf).Encode(d) } + +func (d *dataObject) migrate(ctx context.Context, storage storageAdapter, filename, password string) error { + needsMigrate := true + + for needsMigrate { + switch d.SchemaVersion { + + case 0: // Initial data file created before v2.0.0 + // Ensure a bcrypt hashed password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(err, "Unable to generate bcrypt hash") + } + d.MetaData.Password = string(hashedPassword) + + default: // No migration for this schema version defined, everything fine + needsMigrate = false + + } + + // Increase schema version, see if there are more migrates + d.SchemaVersion++ + } + + return d.writeToStorage(ctx, storage, filename) +} + +func (d dataObject) writeToStorage(ctx context.Context, storage storageAdapter, filename string) error { + buf := bytes.NewBuffer([]byte{}) + if err := json.NewEncoder(buf).Encode(d); err != nil { + return errors.Wrap(err, "Unable to marshal data object") + } + return errors.Wrap(storage.Write(ctx, filename, buf), "Unable to write data file to storage") +} diff --git a/gzip.go b/gzip.go new file mode 100644 index 0000000..d60a43b --- /dev/null +++ b/gzip.go @@ -0,0 +1,46 @@ +package main + +import ( + "compress/gzip" + "io" + "net/http" + "strings" +) + +// Gzip Compression +type gzipResponseWriter struct { + io.Writer + http.ResponseWriter +} + +func (w gzipResponseWriter) Write(b []byte) (int, error) { + return w.Writer.Write(b) +} + +func gzipHandler(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + handler.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + defer gz.Close() + gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w} + handler.ServeHTTP(gzw, r) + }) +} + +func gzipFunc(f http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + f(w, r) + return + } + w.Header().Set("Content-Encoding", "gzip") + gz := gzip.NewWriter(w) + defer gz.Close() + gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w} + f(gzw, r) + } +} diff --git a/httpHelper.go b/httpHelper.go deleted file mode 100644 index 9ec6206..0000000 --- a/httpHelper.go +++ /dev/null @@ -1,64 +0,0 @@ -package main - -import ( - "context" - "net/http" - - "github.com/flosch/pongo2" - "github.com/gorilla/sessions" - log "github.com/sirupsen/logrus" -) - -type httpHelperFunc func(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) - -func httpHelper(f httpHelperFunc) http.HandlerFunc { - return func(res http.ResponseWriter, r *http.Request) { - sess, _ := cookieStore.Get(r, "cloudkeys-go") - ctx := pongo2.Context{} - - if errFlash := sess.Flashes("error"); len(errFlash) > 0 { - ctx["error"] = errFlash[0].(string) - } - - c := getContext(r) - - template, err := f(c, res, r, sess, &ctx) - if err != nil { - http.Error(res, "An error ocurred.", http.StatusInternalServerError) - log.WithError(err).Error("Unable to execute template") - return - } - - if template != nil { - ts := pongo2.NewSet("frontend", pongo2.MustNewLocalFileSystemLoader("templates")) - tpl, err := ts.FromFile(*template) - if err != nil { - log.WithError(err).WithFields(log.Fields{ - "template": *template, - }).Error("Could not parse template") - http.Error(res, "An error ocurred.", http.StatusInternalServerError) - return - } - out, err := tpl.Execute(ctx) - if err != nil { - log.WithError(err).WithFields(log.Fields{ - "template": *template, - }).Error("Could not execute template") - http.Error(res, "An error ocurred.", http.StatusInternalServerError) - return - } - - res.Write([]byte(out)) - } - } -} - -func simpleTemplateOutput(template string) httpHelperFunc { - return func(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) { - return &template, nil - } -} - -func stringPointer(s string) *string { - return &s -} diff --git a/login.go b/login.go deleted file mode 100644 index d372b57..0000000 --- a/login.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "context" - "crypto/sha1" - "fmt" - "net/http" - "strconv" - "strings" - - "golang.org/x/crypto/bcrypt" - - "github.com/flosch/pongo2" - "github.com/gorilla/mux" - "github.com/gorilla/sessions" - log "github.com/sirupsen/logrus" -) - -func loginHandler(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) { - var ( - username = strings.ToLower(r.FormValue("username")) - password = r.FormValue("password") - deprecatedPassword = fmt.Sprintf("%x", sha1.Sum([]byte(cfg.PasswordSalt+r.FormValue("password")))) // Here for backwards compatibility - ) - - if !storage.IsPresent(c, createUserFilename(username)) { - (*ctx)["error"] = true - return stringPointer("login.html"), nil - } - - userFileRaw, err := storage.Read(c, createUserFilename(username)) - if err != nil { - log.WithError(err).Error("Unable to read user file") - (*ctx)["error"] = true - return stringPointer("login.html"), nil - } - - userFile, _ := readDataObject(userFileRaw) - - bcryptValidationError := bcrypt.CompareHashAndPassword([]byte(userFile.MetaData.Password), []byte(password)) - if bcryptValidationError != nil && userFile.MetaData.Password != deprecatedPassword { - (*ctx)["error"] = true - return stringPointer("login.html"), nil - } - - auth, ok := session.Values["authorizedAccounts"].(authorizedAccounts) - if !ok { - auth = authorizedAccounts{} - } - - for i, v := range auth { - if v.Name == username { - http.Redirect(res, r, fmt.Sprintf("u/%d/overview", i), http.StatusFound) - return nil, nil - } - } - - auth = append(auth, authorizedAccount{ - Name: username, - UserFile: createUserFilename(username), - }) - - session.Values["authorizedAccounts"] = auth - if err := session.Save(r, res); err != nil { - return nil, err - } - - http.Redirect(res, r, fmt.Sprintf("u/%d/overview", len(auth)-1), http.StatusFound) - return nil, nil -} - -func logoutHandler(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) { - session.Values["authorizedAccounts"] = authorizedAccounts{} - session.Save(r, res) - http.Redirect(res, r, "overview", http.StatusFound) - return nil, nil -} - -func checkLogin(c context.Context, r *http.Request, session *sessions.Session) (*authorizedAccount, error) { - vars := mux.Vars(r) - idx, err := strconv.ParseInt(vars["userIndex"], 10, 64) - if err != nil { - return nil, err - } - - auth, ok := session.Values["authorizedAccounts"].(authorizedAccounts) - if !ok { - auth = authorizedAccounts{} - } - - if len(auth)-1 < int(idx) { - return nil, nil - } - - return &auth[idx], nil -} diff --git a/overview.go b/overview.go deleted file mode 100644 index c49dcaf..0000000 --- a/overview.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "net/http" - - "github.com/flosch/pongo2" - "github.com/gorilla/sessions" -) - -func overviewHandler(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) { - user, _ := checkLogin(c, r, session) - - if user == nil || !storage.IsPresent(c, user.UserFile) { - http.Redirect(res, r, "../../login", http.StatusFound) - return nil, nil - } - - frontendAccounts := []string{} - idx := -1 - for i, v := range session.Values["authorizedAccounts"].(authorizedAccounts) { - frontendAccounts = append(frontendAccounts, v.Name) - if v.Name == user.Name { - idx = i - } - } - - (*ctx)["authorized_accounts"] = frontendAccounts - (*ctx)["current_user_index"] = idx - - return stringPointer("overview.html"), nil -} diff --git a/register.go b/register.go deleted file mode 100644 index c2333e6..0000000 --- a/register.go +++ /dev/null @@ -1,50 +0,0 @@ -package main - -import ( - "context" - "net/http" - "strings" - - "golang.org/x/crypto/bcrypt" - - "github.com/flosch/pongo2" - "github.com/gorilla/sessions" - log "github.com/sirupsen/logrus" -) - -func registerHandler(c context.Context, res http.ResponseWriter, r *http.Request, session *sessions.Session, ctx *pongo2.Context) (*string, error) { - var ( - username = strings.ToLower(r.FormValue("username")) - password = r.FormValue("password") - passwordCheck = r.FormValue("password_repeat") - ) - - if username == "" || password == "" || password != passwordCheck { - return stringPointer("register.html"), nil - } - - if storage.IsPresent(c, createUserFilename(username)) { - (*ctx)["exists"] = true - return stringPointer("register.html"), nil - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - log.WithError(err).Error("Could not hash user password") - (*ctx)["error"] = true - return stringPointer("register.html"), nil - } - - d := dataObject{} - d.MetaData.Password = string(hashedPassword) - data, _ := d.GetData() - - if err := storage.Write(c, createUserFilename(username), data); err != nil { - log.WithError(err).Error("Could not write user file to storage") - (*ctx)["error"] = true - return stringPointer("register.html"), nil - } - - (*ctx)["created"] = true - return stringPointer("register.html"), nil -} diff --git a/router.go b/router.go index 8e91ec9..3dc89a1 100644 --- a/router.go +++ b/router.go @@ -8,36 +8,23 @@ import ( func router() *mux.Router { r := mux.NewRouter() - r.PathPrefix("/assets/").HandlerFunc(serveAssets) + r.PathPrefix("/assets/").HandlerFunc(gzipFunc(serveAssets)) - r.HandleFunc("/register", httpHelper(simpleTemplateOutput("register.html"))). - Methods("GET") - r.HandleFunc("/register", httpHelper(registerHandler)). - Methods("POST") - - r.HandleFunc("/login", httpHelper(simpleTemplateOutput("login.html"))). - Methods("GET") - r.HandleFunc("/login", httpHelper(loginHandler)). - Methods("POST") - - r.HandleFunc("/logout", httpHelper(logoutHandler)). - Methods("GET") - - r.HandleFunc("/u/{userIndex:[0-9]+}/overview", httpHelper(overviewHandler)). - Methods("GET") - - r.HandleFunc("/u/{userIndex:[0-9]+}/ajax", httpHelper(ajaxGetHandler)). - Methods("GET") - r.HandleFunc("/u/{userIndex:[0-9]+}/ajax", httpHelper(ajaxPostHandler)). - Methods("POST") - - /* --- SUPPORT FOR DEPRECATED METHODS --- */ - r.HandleFunc("/", func(res http.ResponseWriter, r *http.Request) { - http.Redirect(res, r, "u/0/overview", http.StatusFound) - }).Methods("GET") - r.HandleFunc("/overview", func(res http.ResponseWriter, r *http.Request) { - http.Redirect(res, r, "u/0/overview", http.StatusFound) - }).Methods("GET") + registerAPIv2(r.PathPrefix("/v2").Subrouter()) return r } + +func registerAPIv2(r *mux.Router) { + r.HandleFunc("/login", apiHelper(apiLogin)).Methods(http.MethodPost) + r.HandleFunc("/register", apiHelper(apiRegister)).Methods(http.MethodPost) + r.HandleFunc("/users", apiHelper(apiListUsers)).Methods(http.MethodGet) + + r.HandleFunc("/user/{user}/data", apiHelper(apiGetUserData)).Methods(http.MethodGet) + r.HandleFunc("/user/{user}/data", apiHelper(apiSetUserData)).Methods(http.MethodPut) + r.HandleFunc("/user/{user}/logout", apiHelper(apiLogoutUser)).Methods(http.MethodPost) + r.HandleFunc("/user/{user}/settings", apiHelper(apiGetUserSettings)).Methods(http.MethodGet) + r.HandleFunc("/user/{user}/settings", apiHelper(apiSetUserSettings)).Methods(http.MethodPatch) + r.HandleFunc("/user/{user}/password", apiHelper(apiChangeLoginPassword)).Methods(http.MethodPut) + r.HandleFunc("/user/{user}/validate-mfa", apiHelper(apiValidateMFA)).Methods(http.MethodPost) +} diff --git a/sessionData.go b/sessionData.go new file mode 100644 index 0000000..49e9a3b --- /dev/null +++ b/sessionData.go @@ -0,0 +1,17 @@ +package main + +type sessionData struct { + // The current state of all logged in users + Users map[string]userState + // MFA secrets are encrypted with users password, we need to + // store them inside the encrypted session until the user verified + // themselves + MFACache map[string]string +} + +func newSessionData() *sessionData { + return &sessionData{ + Users: make(map[string]userState), + MFACache: make(map[string]string), + } +} diff --git a/storage.go b/storage.go index 45393e5..199ba1b 100644 --- a/storage.go +++ b/storage.go @@ -17,6 +17,7 @@ type storageAdapter interface { IsPresent(ctx context.Context, identifier string) bool Backup(ctx context.Context, identifier string) error } + type storageAdapterInitializer func(*url.URL) (storageAdapter, error) func getStorageAdapter(cfg *config) (storageAdapter, error) {