diff --git a/README.md b/README.md index 284cd3f..5a7b457 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,8 @@ curl --data "username=abc&password=def" localhost:5000/signup curl --data "username=abc&password=def" localhost:5000/login curl --header "Auth: " localhost:5000/me ``` + +## Build status: + + - `master` - [![Build Status](https://magnum.travis-ci.com/lavab/api.svg?token=kJbppXeTxzqpCVvt4t5X&branch=master)](https://magnum.travis-ci.com/lavab/api) + - `develop` - [![Build Status](https://magnum.travis-ci.com/lavab/api.svg?token=kJbppXeTxzqpCVvt4t5X&branch=develop)](https://magnum.travis-ci.com/lavab/api) \ No newline at end of file diff --git a/env/config.go b/env/config.go index e0d4bdd..edd5391 100644 --- a/env/config.go +++ b/env/config.go @@ -1,6 +1,6 @@ package env -type Config struct { +type Flags struct { BindAddress string APIVersion string LogFormatterType string diff --git a/env/env.go b/env/env.go index 9fd89bb..7206e55 100644 --- a/env/env.go +++ b/env/env.go @@ -7,16 +7,10 @@ import ( "github.com/lavab/api/db" ) -type Environment struct { - Log *logrus.Logger - Config *Config - Rethink *gorethink.Session - R *R -} - -type R struct { +var ( + Config *Flags + Log *logrus.Logger + Rethink *gorethink.Session Accounts *db.AccountsTable Tokens *db.TokensTable -} - -var G *Environment +) diff --git a/main.go b/main.go index d9e9e08..f300930 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "github.com/Sirupsen/logrus" "github.com/dancannon/gorethink" - "github.com/goji/glogrus" + "github.com/lavab/glogrus" "github.com/namsral/flag" "github.com/zenazn/goji/graceful" "github.com/zenazn/goji/web" @@ -28,6 +28,7 @@ var ( apiVersion = flag.String("version", "v0", "Shown API version") logFormatterType = flag.String("log", "text", "Log formatter type. Either \"json\" or \"text\"") sessionDuration = flag.Int("session_duration", 72, "Session duration expressed in hours") + forceColors = flag.Bool("force_colors", false, "Force colored prompt?") // Database-related flags rethinkdbURL = flag.String("rethinkdb_url", func() string { address := os.Getenv("RETHINKDB_PORT_28015_TCP_ADDR") @@ -50,16 +51,29 @@ func main() { // Parse the flags flag.Parse() + // Put config into the environment package + env.Config = &env.Flags{ + BindAddress: *bindAddress, + APIVersion: *apiVersion, + LogFormatterType: *logFormatterType, + SessionDuration: *sessionDuration, + } + // Set up a new logger log := logrus.New() // Set the formatter depending on the passed flag's value if *logFormatterType == "text" { - log.Formatter = &logrus.TextFormatter{} + log.Formatter = &logrus.TextFormatter{ + ForceColors: *forceColors, + } } else if *logFormatterType == "json" { log.Formatter = &logrus.JSONFormatter{} } + // Pass it to the environment package + env.Log = log + // Set up the database rethinkOpts := gorethink.ConnectOpts{ Address: *rethinkdbURL, @@ -83,22 +97,23 @@ func main() { }).Fatal("Unable to connect to the database") } + // Put the RethinkDB session into the environment package + env.Rethink = rethinkSession + // Initialize the tables - tables := &env.R{ - Accounts: &db.AccountsTable{ - RethinkCRUD: db.NewCRUDTable( - rethinkSession, - rethinkOpts.Database, - "accounts", - ), - }, - Tokens: &db.TokensTable{ - RethinkCRUD: db.NewCRUDTable( - rethinkSession, - rethinkOpts.Database, - "tokens", - ), - }, + env.Accounts = &db.AccountsTable{ + RethinkCRUD: db.NewCRUDTable( + rethinkSession, + rethinkOpts.Database, + "accounts", + ), + } + env.Tokens = &db.TokensTable{ + RethinkCRUD: db.NewCRUDTable( + rethinkSession, + rethinkOpts.Database, + "tokens", + ), } // Create a new goji mux @@ -116,7 +131,7 @@ func main() { // Set up an auth'd mux auth := web.New() - mux.Use(routes.AuthMiddleware) + auth.Use(routes.AuthMiddleware) // Index route mux.Get("/", routes.Hello) @@ -167,7 +182,7 @@ func main() { auth.Post("/keys/:id/vote", routes.KeysVote) // Merge the muxes - mux.Handle("/", auth) + mux.Handle("/*", auth) // Compile the routes mux.Compile() @@ -175,19 +190,6 @@ func main() { // Make the mux handle every request http.Handle("/", mux) - // Set up a new environment object - env.G = &env.Environment{ - Log: log, - Config: &env.Config{ - BindAddress: *bindAddress, - APIVersion: *apiVersion, - LogFormatterType: *logFormatterType, - SessionDuration: *sessionDuration, - }, - Rethink: rethinkSession, - R: tables, - } - // Log that we're starting the server log.WithFields(logrus.Fields{ "address": *bindAddress, diff --git a/models/account.go b/models/account.go index 8d9fff0..7396dcd 100644 --- a/models/account.go +++ b/models/account.go @@ -1,5 +1,10 @@ package models +import ( + "github.com/gyepisam/mcf" + _ "github.com/gyepisam/mcf/scrypt" +) + // Account stores essential data for a Lavaboom user, and is thus not encrypted. type Account struct { Resource @@ -34,6 +39,47 @@ type Account struct { Type string `json:"type" gorethink:"type"` } +// SetPassword changes the account's password +func (a *Account) SetPassword(password string) error { + encrypted, err := mcf.Create(password) + if err != nil { + return err + } + + a.Password = encrypted + return nil +} + +// VerifyPassword checks if password is valid and upgrades it if its encrypting scheme was outdated +// Returns isValid, wasUpdated, error +func (a *Account) VerifyPassword(password string) (bool, bool, error) { + isValid, err := mcf.Verify(password, a.Password) + if err != nil { + return false, false, err + } + + if !isValid { + return false, false, nil + } + + isCurrent, err := mcf.IsCurrent(a.Password) + if err != nil { + return false, false, err + } + + if !isCurrent { + err := a.SetPassword(password) + if err != nil { + return true, false, err + } + + a.Touch() + return true, true, nil + } + + return true, false, nil +} + // SettingsData TODO type SettingsData struct { } diff --git a/routes/accounts.go b/routes/accounts.go index 2103fa3..1af1d68 100644 --- a/routes/accounts.go +++ b/routes/accounts.go @@ -44,7 +44,7 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { var input AccountsCreateRequest err := utils.ParseRequest(r, input) if err != nil { - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "error": err, }).Warn("Unable to decode a request") @@ -56,7 +56,7 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { } // Ensure that the user with requested username doesn't exist - if _, err := env.G.R.Accounts.FindAccountByName(input.Username); err != nil { + if _, err := env.Accounts.FindAccountByName(input.Username); err != nil { utils.JSONResponse(w, 409, &AccountsCreateResponse{ Success: false, Message: "Username already exists", @@ -64,36 +64,34 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { return } - // Try to hash the password - hash, err := utils.BcryptHash(input.Password) + // TODO: sanitize user name (i.e. remove caps, periods) + + // Create a new user object + account := &models.Account{ + Resource: models.MakeResource("", input.Username), + } + + err = account.SetPassword(input.Password) if err != nil { utils.JSONResponse(w, 500, &AccountsCreateResponse{ Success: false, Message: "Internal server error - AC/CR/01", }) - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "error": err, - }).Error("Unable to hash a password") + }).Error("Unable to hash the password") return } - // TODO: sanitize user name (i.e. remove caps, periods) - - // Create a new user object - account := &models.Account{ - Resource: models.MakeResource("", input.Username), - Password: string(hash), - } - // Try to save it in the database - if err := env.G.R.Accounts.Insert(account); err != nil { + if err := env.Accounts.Insert(account); err != nil { utils.JSONResponse(w, 500, &AccountsCreateResponse{ Success: false, Message: "Internal server error - AC/CR/02", }) - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "error": err, }).Error("Could not insert an user to the database") return @@ -114,7 +112,7 @@ type AccountsGetResponse struct { } // AccountsGet returns the information about the specified account -func AccountsGet(c *web.C, w http.ResponseWriter, r *http.Request) { +func AccountsGet(c web.C, w http.ResponseWriter, r *http.Request) { // Get the account ID from the request id, ok := c.URLParams["id"] if !ok { @@ -138,22 +136,22 @@ func AccountsGet(c *web.C, w http.ResponseWriter, r *http.Request) { session := c.Env["session"].(*models.Token) // Fetch the user object from the database - user, err := env.G.R.Accounts.GetAccount(session.Owner) + user, err := env.Accounts.GetAccount(session.Owner) if err != nil { // The session refers to a non-existing user - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "id": session.ID, "error": err, }).Warn("Valid session referred to a removed account") // Try to remove the orphaned session - if err := env.G.R.Tokens.DeleteID(session.ID); err != nil { - env.G.Log.WithFields(logrus.Fields{ + if err := env.Tokens.DeleteID(session.ID); err != nil { + env.Log.WithFields(logrus.Fields{ "id": session.ID, "error": err, }).Error("Unable to remove an orphaned session") } else { - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "id": session.ID, }).Info("Removed an orphaned session") } diff --git a/routes/hello.go b/routes/hello.go index 7594175..9185b48 100644 --- a/routes/hello.go +++ b/routes/hello.go @@ -19,6 +19,6 @@ func Hello(w http.ResponseWriter, r *http.Request) { utils.JSONResponse(w, 200, &HelloResponse{ Message: "Lavaboom API", DocsURL: "http://lavaboom.readme.io/", - Version: env.G.Config.APIVersion, + Version: env.Config.APIVersion, }) } diff --git a/routes/middleware.go b/routes/middleware.go index ca06437..8111a78 100644 --- a/routes/middleware.go +++ b/routes/middleware.go @@ -39,9 +39,9 @@ func AuthMiddleware(c *web.C, h http.Handler) http.Handler { } // Get the token from the database - token, err := env.G.R.Tokens.GetToken(headerParts[1]) + token, err := env.Tokens.GetToken(headerParts[1]) if err != nil { - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "error": err, }).Error("Cannot retrieve session from the database") @@ -58,7 +58,7 @@ func AuthMiddleware(c *web.C, h http.Handler) http.Handler { Success: false, Message: "Authorization token has expired", }) - env.G.R.Tokens.DeleteID(token.ID) + env.Tokens.DeleteID(token.ID) return } diff --git a/routes/tokens.go b/routes/tokens.go index 902eee9..f6ba539 100644 --- a/routes/tokens.go +++ b/routes/tokens.go @@ -21,7 +21,7 @@ type TokensGetResponse struct { } // TokensGet returns information about the current token. -func TokensGet(c *web.C, w http.ResponseWriter, r *http.Request) { +func TokensGet(c web.C, w http.ResponseWriter, r *http.Request) { // Fetch the current session from the database session := c.Env["session"].(*models.Token) @@ -52,7 +52,7 @@ func TokensCreate(w http.ResponseWriter, r *http.Request) { var input TokensCreateRequest err := utils.ParseRequest(r, input) if err != nil { - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "error": err, }).Warn("Unable to decode a request") @@ -63,9 +63,19 @@ func TokensCreate(w http.ResponseWriter, r *http.Request) { return } - // Authenticate the user - user, err := env.G.R.Accounts.FindAccountByName(input.Username) - if err != nil || !utils.BcryptVerify(user.Password, input.Password) { + // Check if account exists + user, err := env.Accounts.FindAccountByName(input.Username) + if err != nil { + utils.JSONResponse(w, 403, &TokensCreateResponse{ + Success: false, + Message: "Wrong username or password", + }) + return + } + + // Verify the password + valid, updated, err := user.VerifyPassword(input.Password) + if err != nil || !valid { utils.JSONResponse(w, 403, &TokensCreateResponse{ Success: false, Message: "Wrong username or password", @@ -73,8 +83,19 @@ func TokensCreate(w http.ResponseWriter, r *http.Request) { return } + // Update the user if password was updated + if updated { + err := env.Accounts.UpdateID(user.ID, user) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "user": user.Name, + "error": err, + }).Error("Could not update user") + } + } + // Calculate the expiry date - expDate := time.Now().Add(time.Hour * time.Duration(env.G.Config.SessionDuration)) + expDate := time.Now().Add(time.Hour * time.Duration(env.Config.SessionDuration)) // Create a new token token := &models.Token{ @@ -83,7 +104,7 @@ func TokensCreate(w http.ResponseWriter, r *http.Request) { } // Insert int into the database - env.G.R.Tokens.Insert(token) + env.Tokens.Insert(token) // Respond with the freshly created token utils.JSONResponse(w, 201, &TokensCreateResponse{ @@ -100,13 +121,13 @@ type TokensDeleteResponse struct { } // TokensDelete destroys the current session token. -func TokensDelete(c *web.C, w http.ResponseWriter, r *http.Request) { +func TokensDelete(c web.C, w http.ResponseWriter, r *http.Request) { // Get the session from the middleware session := c.Env["session"].(*models.Token) // Delete it from the database - if err := env.G.R.Tokens.DeleteID(session.ID); err != nil { - env.G.Log.WithFields(logrus.Fields{ + if err := env.Tokens.DeleteID(session.ID); err != nil { + env.Log.WithFields(logrus.Fields{ "error": err, }).Error("Unable to delete a session") diff --git a/utils/crypto.go b/utils/crypto.go deleted file mode 100644 index d8ee476..0000000 --- a/utils/crypto.go +++ /dev/null @@ -1,28 +0,0 @@ -package utils - -import ( - "fmt" - "strings" - - "code.google.com/p/go.crypto/bcrypt" -) - -const bcryptCost = 13 - -// BcryptHash TODO -func BcryptHash(src ...string) (string, error) { - in := strings.Join(src, "") - out, err := bcrypt.GenerateFromPassword([]byte(in), bcryptCost) - if err != nil { - return "", fmt.Errorf("bcrypt hashing has failed") - } - return string(out), nil -} - -// BcryptVerify TODO -func BcryptVerify(hashed, plain string) bool { - if bcrypt.CompareHashAndPassword([]byte(hashed), []byte(plain)) == nil { - return true - } - return false -} diff --git a/utils/requests.go b/utils/requests.go index 83fe3d9..ecd1309 100644 --- a/utils/requests.go +++ b/utils/requests.go @@ -32,7 +32,7 @@ func JSONResponse(w http.ResponseWriter, status int, data interface{}) { result, err := json.Marshal(data) if err != nil { // Log the error - env.G.Log.WithFields(logrus.Fields{ + env.Log.WithFields(logrus.Fields{ "error": err, }).Error("Unable to marshal a message")