mirror of
https://github.com/Luzifer/cloudkeys-go.git
synced 2024-11-09 22:50:05 +00:00
Reimplement Go server as API-Server
Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
parent
ed73116f3a
commit
b4793cbab5
12 changed files with 597 additions and 384 deletions
107
ajax.go
107
ajax.go
|
@ -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
|
||||
}
|
115
api.go
Normal file
115
api.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
343
api_funcs.go
Normal file
343
api_funcs.go
Normal file
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
|
|
46
gzip.go
Normal file
46
gzip.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
96
login.go
96
login.go
|
@ -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
|
||||
}
|
32
overview.go
32
overview.go
|
@ -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
|
||||
}
|
50
register.go
50
register.go
|
@ -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
|
||||
}
|
45
router.go
45
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)
|
||||
}
|
||||
|
|
17
sessionData.go
Normal file
17
sessionData.go
Normal file
|
@ -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),
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue