diff --git a/README.md b/README.md index 5a7b457..923460f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,50 @@ curl --data "username=abc&password=def" localhost:5000/login curl --header "Auth: " localhost:5000/me ``` +## Configuration variables + +You can use either commandline flags: +``` +{ api } master » ./api -help +Usage of api: + -bind=":5000": Network address used to bind + -classic_registration=false: Classic registration enabled? + -force_colors=false: Force colored prompt? + -log="text": Log formatter type. Either "json" or "text" + -rethinkdb_db="dev": Database name on the RethinkDB server + -rethinkdb_key="": Authentication key of the RethinkDB database + -rethinkdb_url="localhost:28015": Address of the RethinkDB database + -session_duration=72: Session duration expressed in hours + -version="v0": Shown API version +``` + +Or environment variables: +``` +{ api } master » BIND=127.0.0.1:5000 CLASSIC_REGISTRATION=false \ +FORCE_COLORS=false LOG=text RETHINKDB_DB=dev RETHINKDB_KEY="" \ +RETHINKDB_URL=localhost:28015 SESSION_DURATION=72 VERSION=v0 ./api +``` + +Or configuration files: +``` +{ api } master » cat api.conf +# lines beggining with a "#" character are treated as comments +bind :5000 +classic_registration false +force_colors false +log text + +rethinkdb_db dev +# configuration values can be empty +rethinkdb_key +# Keys and values can be also seperated by the "=" character +rethinkdb_url=localhost:28015 + +session_duration=72 +version=v0 +{ api } master » ./api -config api.conf +``` + ## Build status: - `master` - [![Build Status](https://magnum.travis-ci.com/lavab/api.svg?token=kJbppXeTxzqpCVvt4t5X&branch=master)](https://magnum.travis-ci.com/lavab/api) diff --git a/db/table_tokens.go b/db/table_tokens.go index ddb9cd5..83f54f0 100644 --- a/db/table_tokens.go +++ b/db/table_tokens.go @@ -19,3 +19,10 @@ func (t *TokensTable) GetToken(id string) (*models.Token, error) { return &result, nil } + +// DeleteByOwner deletes all tokens owned by id +func (t *TokensTable) DeleteByOwner(id string) error { + return t.Delete(map[string]interface{}{ + "owner": id, + }) +} diff --git a/env/config.go b/env/config.go index 4944b29..40d8551 100644 --- a/env/config.go +++ b/env/config.go @@ -2,8 +2,9 @@ package env // Flags contains values of flags which are important in the whole API type Flags struct { - BindAddress string - APIVersion string - LogFormatterType string - SessionDuration int + BindAddress string + APIVersion string + LogFormatterType string + SessionDuration int + ClassicRegistration bool } diff --git a/main.go b/main.go index 10733c5..9cbcef4 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,8 @@ var ( 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?") + // Registration settings + classicRegistration = flag.Bool("classic_registration", false, "Classic registration enabled?") // Database-related flags rethinkdbURL = flag.String("rethinkdb_url", func() string { address := os.Getenv("RETHINKDB_PORT_28015_TCP_ADDR") @@ -53,10 +55,11 @@ func main() { // Put config into the environment package env.Config = &env.Flags{ - BindAddress: *bindAddress, - APIVersion: *apiVersion, - LogFormatterType: *logFormatterType, - SessionDuration: *sessionDuration, + BindAddress: *bindAddress, + APIVersion: *apiVersion, + LogFormatterType: *logFormatterType, + SessionDuration: *sessionDuration, + ClassicRegistration: *classicRegistration, } // Set up a new logger @@ -150,12 +153,12 @@ func main() { auth.Put("/accounts/:id", routes.AccountsUpdate) auth.Delete("/accounts/:id", routes.AccountsDelete) auth.Post("/accounts/:id/wipe-data", routes.AccountsWipeData) - auth.Get("/accounts/:id/sessions", routes.AccountsSessionsList) // Tokens auth.Get("/tokens", routes.TokensGet) mux.Post("/tokens", routes.TokensCreate) auth.Delete("/tokens", routes.TokensDelete) + auth.Delete("/tokens/:id", routes.TokensDelete) // Threads auth.Get("/threads", routes.ThreadsList) diff --git a/models/account.go b/models/account.go index d30200d..aa834f1 100644 --- a/models/account.go +++ b/models/account.go @@ -37,6 +37,10 @@ type Account struct { // * premium: premium account // * superuser: Lavaboom staff Type string `json:"type" gorethink:"type"` + + AltEmail string `json:"alt_email" gorethink:"alt_email"` + + Status string `json:"status" gorethink:"status"` } // SetPassword changes the account's password diff --git a/routes/accounts.go b/routes/accounts.go index d57ee33..7b77b64 100644 --- a/routes/accounts.go +++ b/routes/accounts.go @@ -27,8 +27,10 @@ func AccountsList(w http.ResponseWriter, r *http.Request) { // AccountsCreateRequest contains the input for the AccountsCreate endpoint. type AccountsCreateRequest struct { + Token string `json:"token" schema:"token"` Username string `json:"username" schema:"username"` Password string `json:"password" schema:"password"` + AltEmail string `json:"alt_email" schema:"alt_email"` } // AccountsCreateResponse contains the output of the AccountsCreate request. @@ -48,14 +50,92 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { "error": err, }).Warn("Unable to decode a request") - utils.JSONResponse(w, 409, &AccountsCreateResponse{ + utils.JSONResponse(w, 400, &AccountsCreateResponse{ Success: false, Message: "Invalid input format", }) return } - // Ensure that the user with requested username doesn't exist + // Detect the request type + // 1) username + token + password - invite + // 2) username + password + alt_email - register with confirmation + // 3) alt_email only - register for beta (add to queue) + requestType := "unknown" + if input.AltEmail == "" && input.Username != "" && input.Password != "" && input.Token != "" { + requestType = "invited" + } else if input.AltEmail != "" && input.Username != "" && input.Password != "" && input.Token != "" { + requestType = "classic" + } else if input.AltEmail != "" && input.Username == "" && input.Password == "" && input.Token == "" { + requestType = "queue" + } + + // "unknown" requests are empty and invalid + if requestType == "invalid" { + utils.JSONResponse(w, 400, &AccountsCreateResponse{ + Success: false, + Message: "Invalid request", + }) + return + } + + // Adding to queue will be implemented soon + if requestType == "queue" { + // Implementation awaits https://trello.com/c/SLM0qK1O/91-account-registration-queue + utils.JSONResponse(w, 501, &AccountsCreateResponse{ + Success: false, + Message: "Sorry, not implemented yet", + }) + return + } + + // Check if classic registration is enabled + if requestType == "classic" && !env.Config.ClassicRegistration { + utils.JSONResponse(w, 403, &AccountsCreateResponse{ + Success: false, + Message: "Classic registration is disabled", + }) + return + } + + // Check "invited" for token validity + if requestType == "invited" { + // Fetch the token from the database + token, err := env.Tokens.GetToken(input.Token) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Warn("Unable to fetch a registration token from the database") + + utils.JSONResponse(w, 400, &AccountsCreateResponse{ + Success: false, + Message: "Invalid invitation token", + }) + return + } + + // Ensure that the token's type is valid + if token.Type != "invite" { + utils.JSONResponse(w, 400, &AccountsCreateResponse{ + Success: false, + Message: "Invalid invitation token", + }) + return + } + + // Check if it's expired + if token.Expired() { + utils.JSONResponse(w, 400, &AccountsCreateResponse{ + Success: false, + Message: "Expired invitation token", + }) + return + } + } + + // TODO: sanitize user name (i.e. remove caps, periods) + + // Both invited and classic require an unique username, so ensure that the user with requested username isn't already used if _, err := env.Accounts.FindAccountByName(input.Username); err == nil { utils.JSONResponse(w, 409, &AccountsCreateResponse{ Success: false, @@ -64,13 +144,13 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { return } - // TODO: sanitize user name (i.e. remove caps, periods) - - // Create a new user object + // Both username and password are filled, so we can create a new account. account := &models.Account{ Resource: models.MakeResource("", input.Username), + Type: "beta", } + // Set the password err = account.SetPassword(input.Password) if err != nil { utils.JSONResponse(w, 500, &AccountsCreateResponse{ @@ -84,6 +164,16 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { return } + // User won't be able to log in until the account gets verified + if requestType == "classic" { + account.Status = "unverified" + } + + // Set the status to invited, because of stats + if requestType == "invited" { + account.Status = "invited" + } + // Try to save it in the database if err := env.Accounts.Insert(account); err != nil { utils.JSONResponse(w, 500, &AccountsCreateResponse{ @@ -97,11 +187,35 @@ func AccountsCreate(w http.ResponseWriter, r *http.Request) { return } - utils.JSONResponse(w, 201, &AccountsCreateResponse{ - Success: true, - Message: "A new account was successfully created", - Account: account, - }) + // Send the email if classic and return a response + if requestType == "classic" { + // TODO: Send emails + + utils.JSONResponse(w, 201, &AccountsCreateResponse{ + Success: true, + Message: "A new account was successfully created, you should receive a confirmation email soon™.", + Account: account, + }) + return + } + + // Remove the token and return a response + if requestType == "invited" { + err := env.Tokens.DeleteID(input.Token) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + "id": input.Token, + }).Error("Could not remove token from database") + } + + utils.JSONResponse(w, 201, &AccountsCreateResponse{ + Success: true, + Message: "A new account was successfully created", + Account: account, + }) + return + } } // AccountsGetResponse contains the result of the AccountsGet request. @@ -170,17 +284,130 @@ func AccountsGet(c web.C, w http.ResponseWriter, r *http.Request) { }) } +// AccountsUpdateRequest contains the input for the AccountsUpdate endpoint. +type AccountsUpdateRequest struct { + Type string `json:"type" schema:"type"` + AltEmail string `json:"alt_email" schema:"alt_email"` + CurrentPassword string `json:"current_password" schema:"current_password"` + NewPassword string `json:"new_password" schema:"new_password"` +} + // AccountsUpdateResponse contains the result of the AccountsUpdate request. type AccountsUpdateResponse struct { - Success bool `json:"success"` - Message string `json:"message"` + Success bool `json:"success"` + Message string `json:"message"` + Account *models.Account `json:"account"` } // AccountsUpdate allows changing the account's information (password etc.) -func AccountsUpdate(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &AccountsUpdateResponse{ +func AccountsUpdate(c web.C, w http.ResponseWriter, r *http.Request) { + // Decode the request + var input AccountsUpdateRequest + err := utils.ParseRequest(r, &input) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Warn("Unable to decode a request") + + utils.JSONResponse(w, 409, &AccountsUpdateResponse{ + Success: false, + Message: "Invalid input format", + }) + return + } + + // Get the account ID from the request + id, ok := c.URLParams["id"] + if !ok { + utils.JSONResponse(w, 409, &AccountsUpdateResponse{ + Success: false, + Message: "Invalid user ID", + }) + return + } + + // Right now we only support "me" as the ID + if id != "me" { + utils.JSONResponse(w, 501, &AccountsUpdateResponse{ + Success: false, + Message: `Only the "me" user is implemented`, + }) + return + } + + // Fetch the current session from the database + session := c.Env["session"].(*models.Token) + + // Fetch the user object from the database + user, err := env.Accounts.GetAccount(session.Owner) + if err != nil { + // The session refers to a non-existing user + 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.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.Log.WithFields(logrus.Fields{ + "id": session.ID, + }).Info("Removed an orphaned session") + } + + utils.JSONResponse(w, 410, &AccountsUpdateResponse{ + Success: false, + Message: "Account disabled", + }) + return + } + + if valid, _, err := user.VerifyPassword(input.CurrentPassword); err != nil || !valid { + utils.JSONResponse(w, 409, &AccountsUpdateResponse{ + Success: false, + Message: "Invalid current password", + }) + return + } + + err = user.SetPassword(input.NewPassword) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Unable to hash a password") + + utils.JSONResponse(w, 500, &AccountsUpdateResponse{ + Success: false, + Message: "Internal error (code AC/UP/01)", + }) + return + } + + if input.AltEmail != "" { + user.AltEmail = input.AltEmail + } + + err = env.Accounts.UpdateID(session.Owner, user) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Unable to update an account") + + utils.JSONResponse(w, 500, &AccountsUpdateResponse{ + Success: false, + Message: "Internal error (code AC/UP/02)", + }) + return + } + + utils.JSONResponse(w, 200, &AccountsUpdateResponse{ Success: false, - Message: `Sorry, not implemented yet`, + Message: "Your account has been successfully updated", + Account: user, }) } @@ -190,11 +417,98 @@ type AccountsDeleteResponse struct { Message string `json:"message"` } -// AccountsDelete allows deleting an account. -func AccountsDelete(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &AccountsDeleteResponse{ - Success: false, - Message: `Sorry, not implemented yet`, +// AccountsDelete deletes an account and everything related to it. +func AccountsDelete(c web.C, w http.ResponseWriter, r *http.Request) { + // Get the account ID from the request + id, ok := c.URLParams["id"] + if !ok { + utils.JSONResponse(w, 409, &AccountsDeleteResponse{ + Success: false, + Message: "Invalid user ID", + }) + return + } + + // Right now we only support "me" as the ID + if id != "me" { + utils.JSONResponse(w, 501, &AccountsDeleteResponse{ + Success: false, + Message: `Only the "me" user is implemented`, + }) + return + } + + // Fetch the current session from the database + session := c.Env["session"].(*models.Token) + + // Fetch the user object from the database + user, err := env.Accounts.GetAccount(session.Owner) + if err != nil { + // The session refers to a non-existing user + 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.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.Log.WithFields(logrus.Fields{ + "id": session.ID, + }).Info("Removed an orphaned session") + } + + utils.JSONResponse(w, 410, &AccountsDeleteResponse{ + Success: false, + Message: "Account disabled", + }) + return + } + + // TODO: Delete contacts + + // TODO: Delete emails + + // TODO: Delete labels + + // TODO: Delete threads + + // Delete tokens + err = env.Tokens.DeleteByOwner(user.ID) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "id": user.ID, + "error": err, + }).Error("Unable to remove account's tokens") + + utils.JSONResponse(w, 500, &AccountsDeleteResponse{ + Success: false, + Message: "Internal error (code AC/DE/05)", + }) + return + } + + // Delete account + err = env.Accounts.DeleteID(user.ID) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + }).Error("Unable to delete an account") + + utils.JSONResponse(w, 500, &AccountsDeleteResponse{ + Success: false, + Message: "Internal error (code AC/DE/06)", + }) + return + } + + utils.JSONResponse(w, 200, &AccountsDeleteResponse{ + Success: true, + Message: "Your account has been successfully deleted", }) } @@ -204,24 +518,83 @@ type AccountsWipeDataResponse struct { Message string `json:"message"` } -// AccountsWipeData allows getting rid of the all data related to the account. -func AccountsWipeData(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &AccountsWipeDataResponse{ - Success: false, - Message: `Sorry, not implemented yet`, - }) -} +// AccountsWipeData wipes all data except the actual account and billing info. +func AccountsWipeData(c web.C, w http.ResponseWriter, r *http.Request) { + // Get the account ID from the request + id, ok := c.URLParams["id"] + if !ok { + utils.JSONResponse(w, 409, &AccountsWipeDataResponse{ + Success: false, + Message: "Invalid user ID", + }) + return + } -// AccountsSessionsListResponse contains the result of the AccountsSessionsList request. -type AccountsSessionsListResponse struct { - Success bool `json:"success"` - Message string `json:"message"` -} + // Right now we only support "me" as the ID + if id != "me" { + utils.JSONResponse(w, 501, &AccountsWipeDataResponse{ + Success: false, + Message: `Only the "me" user is implemented`, + }) + return + } -// AccountsSessionsList returns a list of all opened sessions. -func AccountsSessionsList(w http.ResponseWriter, r *http.Request) { - utils.JSONResponse(w, 501, &AccountsSessionsListResponse{ - Success: false, - Message: `Sorry, not implemented yet`, + // Fetch the current session from the database + session := c.Env["session"].(*models.Token) + + // Fetch the user object from the database + user, err := env.Accounts.GetAccount(session.Owner) + if err != nil { + // The session refers to a non-existing user + 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.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.Log.WithFields(logrus.Fields{ + "id": session.ID, + }).Info("Removed an orphaned session") + } + + utils.JSONResponse(w, 410, &AccountsWipeDataResponse{ + Success: false, + Message: "Account disabled", + }) + return + } + + // TODO: Delete contacts + + // TODO: Delete emails + + // TODO: Delete labels + + // TODO: Delete threads + + // Delete tokens + err = env.Tokens.DeleteByOwner(user.ID) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "id": user.ID, + "error": err, + }).Error("Unable to remove account's tokens") + + utils.JSONResponse(w, 500, &AccountsWipeDataResponse{ + Success: false, + Message: "Internal error (code AC/WD/05)", + }) + return + } + + utils.JSONResponse(w, 200, &AccountsWipeDataResponse{ + Success: true, + Message: "Your account has been successfully wiped", }) } diff --git a/routes/tokens.go b/routes/tokens.go index 346da3c..bfc1e8a 100644 --- a/routes/tokens.go +++ b/routes/tokens.go @@ -131,20 +131,43 @@ type TokensDeleteResponse struct { Message string `json:"message"` } -// TokensDelete destroys the current session token. +// TokensDelete destroys either the current auth token or the one passed as an URL param func TokensDelete(c web.C, w http.ResponseWriter, r *http.Request) { - // Get the session from the middleware - session := c.Env["session"].(*models.Token) + // Initialize + var ( + token *models.Token + err error + ) + + id, ok := c.URLParams["id"] + if !ok || id == "" { + // Get the token from the middleware + token = c.Env["session"].(*models.Token) + } else { + token, err = env.Tokens.GetToken(id) + if err != nil { + env.Log.WithFields(logrus.Fields{ + "error": err, + "id": id, + }).Warn("Unable to find the token") + + utils.JSONResponse(w, 500, &TokensDeleteResponse{ + Success: true, + Message: "Internal server error - TO/DE/01", + }) + return + } + } // Delete it from the database - if err := env.Tokens.DeleteID(session.ID); err != nil { + if err := env.Tokens.DeleteID(token.ID); err != nil { env.Log.WithFields(logrus.Fields{ "error": err, - }).Error("Unable to delete a session") + }).Error("Unable to delete a token") utils.JSONResponse(w, 500, &TokensDeleteResponse{ Success: true, - Message: "Internal server error - TO/DE/01", + Message: "Internal server error - TO/DE/02", }) return }