diff --git a/.env b/.env deleted file mode 100644 index 2092c2e..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -VOCDONI_PORT=8080 -VOCDONI_SECRET=supersecret \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/api/api.go b/api/api.go index 52638ae..25ab3e3 100644 --- a/api/api.go +++ b/api/api.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/jwtauth/v5" "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/db" + "github.com/vocdoni/saas-backend/notifications" "go.vocdoni.io/dvote/apiclient" "go.vocdoni.io/dvote/log" ) @@ -21,13 +22,15 @@ const ( ) type APIConfig struct { - Host string - Port int - Secret string - Chain string - DB *db.MongoStorage - Client *apiclient.HTTPclient - Account *account.Account + Host string + Port int + Secret string + Chain string + DB *db.MongoStorage + Client *apiclient.HTTPclient + Account *account.Account + MailService notifications.NotificationService + SMSService notifications.NotificationService // FullTransparentMode if true allows signing all transactions and does not // modify any of them. FullTransparentMode bool @@ -42,6 +45,8 @@ type API struct { router *chi.Mux client *apiclient.HTTPclient account *account.Account + mail notifications.NotificationService + sms notifications.NotificationService secret string transparentMode bool } @@ -58,6 +63,8 @@ func New(conf *APIConfig) *API { port: conf.Port, client: conf.Client, account: conf.Account, + mail: conf.MailService, + sms: conf.SMSService, secret: conf.Secret, transparentMode: conf.FullTransparentMode, } @@ -102,14 +109,14 @@ func (a *API) initRouter() http.Handler { log.Infow("new route", "method", "GET", "path", authAddressesEndpoint) r.Get(authAddressesEndpoint, a.writableOrganizationAddressesHandler) // get user information - log.Infow("new route", "method", "GET", "path", myUsersEndpoint) - r.Get(myUsersEndpoint, a.userInfoHandler) + log.Infow("new route", "method", "GET", "path", usersMeEndpoint) + r.Get(usersMeEndpoint, a.userInfoHandler) // update user information - log.Infow("new route", "method", "PUT", "path", myUsersEndpoint) - r.Put(myUsersEndpoint, a.updateUserInfoHandler) + log.Infow("new route", "method", "PUT", "path", usersMeEndpoint) + r.Put(usersMeEndpoint, a.updateUserInfoHandler) // update user password - log.Infow("new route", "method", "PUT", "path", myUsersPasswordEndpoint) - r.Put(myUsersPasswordEndpoint, a.updateUserPasswordHandler) + log.Infow("new route", "method", "PUT", "path", usersPasswordEndpoint) + r.Put(usersPasswordEndpoint, a.updateUserPasswordHandler) // sign a payload log.Infow("new route", "method", "POST", "path", signTxEndpoint) r.Post(signTxEndpoint, a.signTxHandler) @@ -133,12 +140,21 @@ func (a *API) initRouter() http.Handler { log.Warnw("failed to write ping response", "error", err) } }) - // register new users - log.Infow("new route", "method", "POST", "path", usersEndpoint) - r.Post(usersEndpoint, a.registerHandler) // login log.Infow("new route", "method", "POST", "path", authLoginEndpoint) r.Post(authLoginEndpoint, a.authLoginHandler) + // register user + log.Infow("new route", "method", "POST", "path", usersEndpoint) + r.Post(usersEndpoint, a.registerHandler) + // verify user + log.Infow("new route", "method", "POST", "path", verifyUserEndpoint) + r.Post(verifyUserEndpoint, a.verifyUserAccountHandler) + // request user password recovery + log.Infow("new route", "method", "POST", "path", usersRecoveryPasswordEndpoint) + r.Post(usersRecoveryPasswordEndpoint, a.recoverUserPasswordHandler) + // reset user password + log.Infow("new route", "method", "POST", "path", usersResetPasswordEndpoint) + r.Post(usersResetPasswordEndpoint, a.resetUserPasswordHandler) // get organization information log.Infow("new route", "method", "GET", "path", organizationEndpoint) r.Get(organizationEndpoint, a.organizationInfoHandler) diff --git a/api/api_test.go b/api/api_test.go index 46e6caf..6af731f 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -11,6 +11,7 @@ import ( "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/db" + "github.com/vocdoni/saas-backend/notifications/testmail" "github.com/vocdoni/saas-backend/test" "go.vocdoni.io/dvote/apiclient" ) @@ -24,17 +25,27 @@ type apiTestCase struct { } const ( - testSecret = "super-secret" - testEmail = "admin@test.com" - testPass = "password123" - testHost = "0.0.0.0" - testPort = 7788 + testSecret = "super-secret" + testEmail = "user@test.com" + testPass = "password123" + testFirstName = "test" + testLastName = "user" + testHost = "0.0.0.0" + testPort = 7788 + + adminEmail = "admin@test.com" + adminUser = "admin" + adminPass = "admin123" ) // testDB is the MongoDB storage for the tests. Make it global so it can be // accessed by the tests directly. var testDB *db.MongoStorage +// testMailService is the test mail service for the tests. Make it global so it +// can be accessed by the tests directly. +var testMailService *testmail.TestMail + // testURL helper function returns the full URL for the given path using the // test host and port. func testURL(path string) string { @@ -95,6 +106,13 @@ func TestMain(m *testing.M) { if err != nil { panic(err) } + // set reset db env var to true + _ = os.Setenv("VOCDONI_MONGO_RESET_DB", "true") + // create a new MongoDB connection with the test database + if testDB, err = db.New(mongoURI, test.RandomDatabaseName()); err != nil { + panic(err) + } + defer testDB.Close() // start the faucet container faucetContainer, err := test.StartVocfaucetContainer(ctx) if err != nil { @@ -113,13 +131,24 @@ func TestMain(m *testing.M) { panic(err) } testAPIEndpoint := test.VoconedAPIURL(apiEndpoint) - // set reset db env var to true - _ = os.Setenv("VOCDONI_MONGO_RESET_DB", "true") - // create a new MongoDB connection with the test database - if testDB, err = db.New(mongoURI, test.RandomDatabaseName()); err != nil { + // start test mail server + testMailServer, err := test.StartMailService(ctx) + if err != nil { + panic(err) + } + // get the host, the SMTP port and the API port + mailHost, err := testMailServer.Host(ctx) + if err != nil { + panic(err) + } + smtpPort, err := testMailServer.MappedPort(ctx, test.MailSMTPPort) + if err != nil { + panic(err) + } + apiPort, err := testMailServer.MappedPort(ctx, test.MailAPIPort) + if err != nil { panic(err) } - defer testDB.Close() // create the remote test API client testAPIClient, err := apiclient.New(testAPIEndpoint) if err != nil { @@ -131,6 +160,18 @@ func TestMain(m *testing.M) { if err != nil { panic(err) } + // create test mail service + testMailService = new(testmail.TestMail) + if err := testMailService.Init(&testmail.TestMailConfig{ + FromAddress: adminEmail, + SMTPUser: adminUser, + SMTPPassword: adminPass, + Host: mailHost, + SMTPPort: smtpPort.Int(), + APIPort: apiPort.Int(), + }); err != nil { + panic(err) + } // start the API New(&APIConfig{ Host: testHost, @@ -139,6 +180,7 @@ func TestMain(m *testing.M) { DB: testDB, Client: testAPIClient, Account: testAccount, + MailService: testMailService, FullTransparentMode: false, }).Start() // wait for the API to start diff --git a/api/auth.go b/api/auth.go index 1d97d83..c50f3cd 100644 --- a/api/auth.go +++ b/api/auth.go @@ -1,11 +1,11 @@ package api import ( - "encoding/hex" "encoding/json" "net/http" "github.com/vocdoni/saas-backend/db" + "github.com/vocdoni/saas-backend/internal" ) // refresh handles the refresh request. It returns a new JWT token. @@ -45,11 +45,14 @@ func (a *API) authLoginHandler(w http.ResponseWriter, r *http.Request) { return } // check the password - hPassword := hashPassword(loginInfo.Password) - if hex.EncodeToString(hPassword) != user.Password { + if pass := internal.HexHashPassword(passwordSalt, loginInfo.Password); pass != user.Password { ErrUnauthorized.Write(w) return } + // check if the user is verified + if !user.Verified { + ErrUnauthorized.Withf("user not verified").Write(w) + } // generate a new token with the user name as the subject res, err := a.buildLoginResponse(loginInfo.Email) if err != nil { diff --git a/api/const.go b/api/const.go new file mode 100644 index 0000000..f85ad2a --- /dev/null +++ b/api/const.go @@ -0,0 +1,10 @@ +package api + +const ( + // VerificationCodeLength is the length of the verification code in bytes + VerificationCodeLength = 3 + // VerificationCodeEmailSubject is the subject of the verification code email + VerificationCodeEmailSubject = "Vocdoni verification code" + // VerificationCodeTextBody is the body of the verification code email + VerificationCodeTextBody = "Your Vocdoni verification code is: " +) diff --git a/api/docs.md b/api/docs.md index b96a3bd..81ca778 100644 --- a/api/docs.md +++ b/api/docs.md @@ -13,9 +13,12 @@ - [📝 Sign message](#-sign-message) - [👥 Users](#-users) - [🙋 Register](#-register) + - [✅ Verify user](#-verify-user) - [🧑‍💻 Get current user info](#-get-current-user-info) - [💇 Update current user info](#-update-current-user-info) - [🔏 Update current user password](#-update-current-user-password) + - [⛓️‍💥 Request a password recovery](#-request-a-password-recovery) + - [🔗 Reset user password](#-reset-user-password) - [🏤 Organizations](#-organizations) - [🆕 Create organization](#-create-organization) - [⚙️ Update organization](#-update-organization) @@ -64,14 +67,6 @@ * **Headers** * `Authentication: Bearer ` -* **Response** -```json -{ - "token": "", - "expirity": "2024-08-21T11:26:54.368718+02:00" -} -``` - * **Errors** | HTTP Status | Error code | Message | @@ -192,6 +187,29 @@ This endpoint only returns the addresses of the organizations where the current "password": "secretpass1234" } ``` + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `401` | `40001` | `user not authorized` | +| `400` | `40002` | `email malformed` | +| `400` | `40003` | `password too short` | +| `400` | `40004` | `malformed JSON body` | +| `500` | `50002` | `internal server error` | + +### ✅ Verify user + +* **Path** `/auth/verify` +* **Method** `POST` +* **Request Body** +```json +{ + "email": "user2veryfy@email.com", + "code": "******", +} +``` + * **Response** ```json { @@ -205,8 +223,6 @@ This endpoint only returns the addresses of the organizations where the current | HTTP Status | Error code | Message | |:---:|:---:|:---| | `401` | `40001` | `user not authorized` | -| `400` | `40002` | `email malformed` | -| `400` | `40003` | `password too short` | | `400` | `40004` | `malformed JSON body` | | `500` | `50002` | `internal server error` | @@ -288,7 +304,7 @@ This method invalidates any previous JWT token for the user, so it returns a new ### 🔏 Update current user password -* **Path** `/users/me/password` +* **Path** `/users/password` * **Method** `PUT` * **Request body** ```json @@ -307,6 +323,47 @@ This method invalidates any previous JWT token for the user, so it returns a new | `400` | `40004` | `malformed JSON body` | | `500` | `50002` | `internal server error` | +### ⛓️‍💥 Request a password recovery + +* **Path** `/users/password/recovery` +* **Method** `POST` +* **Request body** +```json +{ + "email": "user@test.com", +} +``` + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `401` | `40001` | `user not authorized` | +| `400` | `40004` | `malformed JSON body` | +| `500` | `50002` | `internal server error` | + +### 🔗 Reset user password + +* **Path** `/users/password/reset` +* **Method** `POST` +* **Request body** +```json +{ + "email": "user@test.com", + "code": "******", + "newPassword": "newpassword123" +} +``` + +* **Errors** + +| HTTP Status | Error code | Message | +|:---:|:---:|:---| +| `401` | `40001` | `user not authorized` | +| `400` | `40003` | `password too short` | +| `400` | `40004` | `malformed JSON body` | +| `500` | `50002` | `internal server error` | + ## 🏤 Organizations ### 🆕 Create organization @@ -333,6 +390,8 @@ This method invalidates any previous JWT token for the user, so it returns a new "language": "EN" } ``` +By default, the organization is created with `activated: true`. + If the user want to create a sub org, the address of the root organization must be provided inside an organization object in `parent` param. The creator must be admin of the parent organization to be able to create suborganizations. Example: ```json { diff --git a/api/helpers.go b/api/helpers.go index af21248..9dc63e7 100644 --- a/api/helpers.go +++ b/api/helpers.go @@ -2,10 +2,8 @@ package api import ( "context" - "crypto/sha256" "encoding/json" "net/http" - "regexp" "time" "github.com/go-chi/chi/v5" @@ -14,18 +12,6 @@ import ( "go.vocdoni.io/dvote/log" ) -var regexpEmail = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) - -// isEmailValid helper function allows to validate an email address. -func isEmailValid(email string) bool { - return regexpEmail.MatchString(email) -} - -// hashPassword helper function allows to hash a password using a salt. -func hashPassword(password string) []byte { - return sha256.New().Sum([]byte(passwordSalt + password)) -} - // organizationFromRequest helper function allows to get the organization info // related to the request provided. It gets the organization address from the // URL parameters and retrieves the organization from the database. If the diff --git a/api/routes.go b/api/routes.go index d2ac33e..107f6ee 100644 --- a/api/routes.go +++ b/api/routes.go @@ -18,11 +18,17 @@ const ( // POST /users to register a new user usersEndpoint = "/users" + // POST /users/verify to verify the user + verifyUserEndpoint = "/users/verify" // GET /users/me to get the current user information // PUT /users/me to update the current user information - myUsersEndpoint = "/users/me" + usersMeEndpoint = "/users/me" // PUT /users/me/password to update the current user password - myUsersPasswordEndpoint = "/users/me/password" + usersPasswordEndpoint = "/users/password" + // POST /users/password/recovery to recover the user password + usersRecoveryPasswordEndpoint = "/users/password/recovery" + // POST /users/password/reset to reset the user password + usersResetPasswordEndpoint = "/users/password/reset" // signer routes // POST /transactions to sign a transaction diff --git a/api/types.go b/api/types.go index 23e1cec..50a7a7b 100644 --- a/api/types.go +++ b/api/types.go @@ -50,9 +50,11 @@ type UserOrganization struct { // UserInfo is the request to register a new user. type UserInfo struct { Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` Password string `json:"password,omitempty"` FirstName string `json:"firstName,omitempty"` LastName string `json:"lastName,omitempty"` + Verified bool `json:"verified,omitempty"` Organizations []*UserOrganization `json:"organizations"` } @@ -62,6 +64,19 @@ type UserPasswordUpdate struct { NewPassword string `json:"newPassword"` } +// UserVerificationRequest is the request to verify a user. +type UserVerification struct { + Email string `json:"email"` + Code string `json:"code"` + Phone string `json:"phone"` +} + +type UserPasswordReset struct { + Email string `json:"email"` + Code string `json:"code"` + NewPassword string `json:"newPassword"` +} + // LoginResponse is the response of the login request which includes the JWT token type LoginResponse struct { Token string `json:"token"` diff --git a/api/users.go b/api/users.go index 03872f4..1a9ada9 100644 --- a/api/users.go +++ b/api/users.go @@ -1,15 +1,66 @@ package api import ( - "encoding/hex" + "context" "encoding/json" + "fmt" "io" "net/http" + "time" "github.com/vocdoni/saas-backend/db" + "github.com/vocdoni/saas-backend/internal" + "github.com/vocdoni/saas-backend/notifications" "go.vocdoni.io/dvote/log" + "go.vocdoni.io/dvote/util" ) +// sendUserCode method allows to send a code to the user via email or SMS. It +// generates a verification code and stores it in the database associated to +// the user email. If the mail service is available, it sends the verification +// code via email. If the SMS service is available, it sends the verification +// code via SMS. The code is generated associated a the type of code received, +// that can be either a verification code or a password reset code. Other types +// of codes can be added in the future. If neither the mail service nor the SMS +// service are available, the verification code will be empty but stored in the +// database to mock the verification process in any case. +func (a *API) sendUserCode(ctx context.Context, user *db.User, codeType db.CodeType) error { + // generate verification code if the mail service is available, if not + // the verification code will not be sent but stored in the database + // generated with just the user email to mock the verification process + var code string + if a.mail != nil || a.sms != nil { + code = util.RandomHex(VerificationCodeLength) + } + hashCode := internal.HashVerificationCode(user.Email, code) + // store the verification code in the database + if err := a.db.SetVerificationCode(&db.User{ID: user.ID}, hashCode, codeType); err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, time.Second*10) + defer cancel() + // send the verification code via email if the mail service is available + if a.mail != nil { + if err := a.mail.SendNotification(ctx, ¬ifications.Notification{ + ToName: fmt.Sprintf("%s %s", user.FirstName, user.LastName), + ToAddress: user.Email, + Subject: VerificationCodeEmailSubject, + Body: VerificationCodeTextBody + code, + }); err != nil { + return err + } + } else if a.sms != nil { + // send the verification code via SMS if the SMS service is available + if err := a.sms.SendNotification(ctx, ¬ifications.Notification{ + ToNumber: user.Phone, + Body: VerificationCodeTextBody + code, + }); err != nil { + return err + } + } + return nil +} + // registerHandler handles the register request. It creates a new user in the database. func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { userInfo := &UserInfo{} @@ -23,7 +74,7 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { return } // check the email is correct format - if !isEmailValid(userInfo.Email) { + if !internal.ValidEmail(userInfo.Email) { ErrEmailMalformed.Write(w) return } @@ -43,14 +94,15 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { return } // hash the password - hPassword := hashPassword(userInfo.Password) + hPassword := internal.HexHashPassword(passwordSalt, userInfo.Password) // add the user to the database - if err := a.db.SetUser(&db.User{ + userID, err := a.db.SetUser(&db.User{ Email: userInfo.Email, FirstName: userInfo.FirstName, LastName: userInfo.LastName, - Password: hex.EncodeToString(hPassword), - }); err != nil { + Password: hPassword, + }) + if err != nil { if err == db.ErrAlreadyExists { ErrMalformedBody.WithErr(err).Write(w) return @@ -59,8 +111,49 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) { ErrGenericInternalServerError.Write(w) return } + // compose the new user and send the verification code + newUser := &db.User{ + ID: userID, + Email: userInfo.Email, + FirstName: userInfo.FirstName, + LastName: userInfo.LastName, + } + if err := a.sendUserCode(r.Context(), newUser, db.CodeTypeAccountVerification); err != nil { + log.Warnw("could not send verification code", "error", err) + ErrGenericInternalServerError.Write(w) + return + } + // send the token back to the user + httpWriteOK(w) +} + +// verifyUserAccountHandler handles the request to verify the user account. It +// requires the user email and the verification code to be provided. If the +// verification code is correct, the user account is verified and a new token is +// generated and sent back to the user. If the verification code is incorrect, +// an error is returned. +func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) { + verification := &UserVerification{} + if err := json.NewDecoder(r.Body).Decode(verification); err != nil { + ErrMalformedBody.Write(w) + return + } + hashCode := internal.HashVerificationCode(verification.Email, verification.Code) + user, err := a.db.UserByVerificationCode(hashCode, db.CodeTypeAccountVerification) + if err != nil { + if err == db.ErrNotFound { + ErrUnauthorized.Write(w) + return + } + ErrGenericInternalServerError.Write(w) + return + } + if err := a.db.VerifyUserAccount(user); err != nil { + ErrGenericInternalServerError.Write(w) + return + } // generate a new token with the user name as the subject - res, err := a.buildLoginResponse(userInfo.Email) + res, err := a.buildLoginResponse(user.Email) if err != nil { ErrGenericInternalServerError.Write(w) return @@ -98,6 +191,7 @@ func (a *API) userInfoHandler(w http.ResponseWriter, r *http.Request) { Email: user.Email, FirstName: user.FirstName, LastName: user.LastName, + Verified: user.Verified, Organizations: userOrgs, }) } @@ -122,7 +216,7 @@ func (a *API) updateUserInfoHandler(w http.ResponseWriter, r *http.Request) { currentEmail := user.Email // check the email is correct format if it is not empty if userInfo.Email != "" { - if !isEmailValid(userInfo.Email) { + if !internal.ValidEmail(userInfo.Email) { ErrEmailMalformed.Write(w) return } @@ -147,7 +241,7 @@ func (a *API) updateUserInfoHandler(w http.ResponseWriter, r *http.Request) { } // update the user information if needed if updateUser { - if err := a.db.SetUser(user); err != nil { + if _, err := a.db.SetUser(user); err != nil { log.Warnw("could not update user", "error", err) ErrGenericInternalServerError.Write(w) return @@ -158,7 +252,7 @@ func (a *API) updateUserInfoHandler(w http.ResponseWriter, r *http.Request) { if err := a.db.ReplaceCreatorEmail(currentEmail, user.Email); err != nil { // revert the user update if the creator email update fails user.Email = currentEmail - if err := a.db.SetUser(user); err != nil { + if _, err := a.db.SetUser(user); err != nil { log.Warnw("could not revert user update", "error", err) } // return an error @@ -196,14 +290,85 @@ func (a *API) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) return } // hash the password the old password to compare it with the stored one - hOldPassword := hex.EncodeToString(hashPassword(userPasswords.OldPassword)) + hOldPassword := internal.HexHashPassword(passwordSalt, userPasswords.OldPassword) if hOldPassword != user.Password { ErrUnauthorized.Withf("old password does not match").Write(w) return } // hash and update the new password - user.Password = hex.EncodeToString(hashPassword(userPasswords.NewPassword)) - if err := a.db.SetUser(user); err != nil { + user.Password = internal.HexHashPassword(passwordSalt, userPasswords.NewPassword) + if _, err := a.db.SetUser(user); err != nil { + log.Warnw("could not update user password", "error", err) + ErrGenericInternalServerError.Write(w) + return + } + httpWriteOK(w) +} + +// recoveryUserPasswordHandler handles the request to recover the password of a +// user. It requires the user email to be provided. If the email is correct, a +// new verification code is generated and sent to the user email. If the email +// is incorrect, an error is returned. +func (a *API) recoverUserPasswordHandler(w http.ResponseWriter, r *http.Request) { + // get the user info from the request body + userInfo := &UserInfo{} + if err := json.NewDecoder(r.Body).Decode(userInfo); err != nil { + ErrMalformedBody.Write(w) + return + } + // get the user information from the database by email + user, err := a.db.UserByEmail(userInfo.Email) + if err != nil { + if err == db.ErrNotFound { + ErrUnauthorized.Write(w) + return + } + ErrGenericInternalServerError.Write(w) + return + } + // check the user is verified + if !user.Verified { + ErrUnauthorized.With("user not verified").Write(w) + return + } + // generate a new verification code + if err := a.sendUserCode(r.Context(), user, db.CodeTypePasswordReset); err != nil { + log.Warnw("could not send verification code", "error", err) + ErrGenericInternalServerError.Write(w) + return + } + httpWriteOK(w) +} + +// resetUserPasswordHandler handles the request to reset the password of a user. +// It requires the user email, the verification code and the new password to be +// provided. If the verification code is correct, the user password is updated +// to the new one. If the verification code is incorrect, an error is returned. +func (a *API) resetUserPasswordHandler(w http.ResponseWriter, r *http.Request) { + userPasswords := &UserPasswordReset{} + if err := json.NewDecoder(r.Body).Decode(userPasswords); err != nil { + ErrMalformedBody.Write(w) + return + } + // check the password is correct format + if len(userPasswords.NewPassword) < 8 { + ErrPasswordTooShort.Write(w) + return + } + // get the user information from the database by the verification code + hashCode := internal.HashVerificationCode(userPasswords.Email, userPasswords.Code) + user, err := a.db.UserByVerificationCode(hashCode, db.CodeTypePasswordReset) + if err != nil { + if err == db.ErrNotFound { + ErrUnauthorized.Write(w) + return + } + ErrGenericInternalServerError.Write(w) + return + } + // hash and update the new password + user.Password = internal.HexHashPassword(passwordSalt, userPasswords.NewPassword) + if _, err := a.db.SetUser(user); err != nil { log.Warnw("could not update user password", "error", err) ErrGenericInternalServerError.Write(w) return diff --git a/api/users_test.go b/api/users_test.go index 25f08f7..ed65c37 100644 --- a/api/users_test.go +++ b/api/users_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "io" "net/http" "strings" @@ -143,3 +144,158 @@ func TestRegisterHandler(t *testing.T) { } } } + +func TestVerifyAccountHandler(t *testing.T) { + c := qt.New(t) + defer func() { + if err := testDB.Reset(); err != nil { + c.Logf("error resetting test database: %v", err) + } + }() + // register a user + jsonUser := mustMarshal(&UserInfo{ + Email: testEmail, + Password: testPass, + FirstName: testFirstName, + LastName: testLastName, + }) + req, err := http.NewRequest(http.MethodPost, testURL(usersEndpoint), bytes.NewBuffer(jsonUser)) + c.Assert(err, qt.IsNil) + resp, err := http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) + // try to login (should fail) + jsonLogin := mustMarshal(&UserInfo{ + Email: testEmail, + Password: testPass, + }) + req, err = http.NewRequest(http.MethodPost, testURL(authLoginEndpoint), bytes.NewBuffer(jsonLogin)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusUnauthorized) + c.Assert(resp.Body.Close(), qt.IsNil) + // get the verification code from the email + mailBody, err := testMailService.FindEmail(context.Background(), testEmail) + c.Assert(err, qt.IsNil) + mailCode := strings.TrimPrefix(mailBody, VerificationCodeTextBody) + // verify the user + verification := mustMarshal(&UserVerification{ + Email: testEmail, + Code: mailCode, + }) + req, err = http.NewRequest(http.MethodPost, testURL(verifyUserEndpoint), bytes.NewBuffer(verification)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) + // try to verify the user again (should fail) + req, err = http.NewRequest(http.MethodPost, testURL(verifyUserEndpoint), bytes.NewBuffer(verification)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusUnauthorized) + c.Assert(resp.Body.Close(), qt.IsNil) + // try to login again + req, err = http.NewRequest(http.MethodPost, testURL(authLoginEndpoint), bytes.NewBuffer(jsonLogin)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) +} + +func TestRecoverAndResetPassword(t *testing.T) { + c := qt.New(t) + defer func() { + if err := testDB.Reset(); err != nil { + c.Logf("error resetting test database: %v", err) + } + }() + // register a user + jsonUser := mustMarshal(&UserInfo{ + Email: testEmail, + Password: testPass, + FirstName: testFirstName, + LastName: testLastName, + }) + req, err := http.NewRequest(http.MethodPost, testURL(usersEndpoint), bytes.NewBuffer(jsonUser)) + c.Assert(err, qt.IsNil) + resp, err := http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) + // try to recover the password before verifying the user (should fail) + jsonRecover := mustMarshal(&UserInfo{ + Email: testEmail, + }) + req, err = http.NewRequest(http.MethodPost, testURL(usersRecoveryPasswordEndpoint), bytes.NewBuffer(jsonRecover)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusUnauthorized) + c.Assert(resp.Body.Close(), qt.IsNil) + // get the verification code from the email + mailBody, err := testMailService.FindEmail(context.Background(), testEmail) + c.Assert(err, qt.IsNil) + verifyMailCode := strings.TrimPrefix(mailBody, VerificationCodeTextBody) + // verify the user + verification := mustMarshal(&UserVerification{ + Email: testEmail, + Code: verifyMailCode, + }) + req, err = http.NewRequest(http.MethodPost, testURL(verifyUserEndpoint), bytes.NewBuffer(verification)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) + // try to recover the password after verifying the user + req, err = http.NewRequest(http.MethodPost, testURL(usersRecoveryPasswordEndpoint), bytes.NewBuffer(jsonRecover)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) + // get the recovery code from the email + mailBody, err = testMailService.FindEmail(context.Background(), testEmail) + c.Assert(err, qt.IsNil) + passResetMailCode := strings.TrimPrefix(mailBody, VerificationCodeTextBody) + // reset the password + newPassword := "password2" + resetPass := mustMarshal(&UserPasswordReset{ + Email: testEmail, + Code: passResetMailCode, + NewPassword: newPassword, + }) + req, err = http.NewRequest(http.MethodPost, testURL(usersResetPasswordEndpoint), bytes.NewBuffer(resetPass)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) + // try to login with the old password (should fail) + jsonLogin := mustMarshal(&UserInfo{ + Email: testEmail, + Password: testPass, + }) + req, err = http.NewRequest(http.MethodPost, testURL(authLoginEndpoint), bytes.NewBuffer(jsonLogin)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusUnauthorized) + c.Assert(resp.Body.Close(), qt.IsNil) + // try to login with the new password + jsonLogin = mustMarshal(&UserInfo{ + Email: testEmail, + Password: newPassword, + }) + req, err = http.NewRequest(http.MethodPost, testURL(authLoginEndpoint), bytes.NewBuffer(jsonLogin)) + c.Assert(err, qt.IsNil) + resp, err = http.DefaultClient.Do(req) + c.Assert(err, qt.IsNil) + c.Assert(resp.StatusCode, qt.Equals, http.StatusOK) + c.Assert(resp.Body.Close(), qt.IsNil) +} diff --git a/cmd/service/main.go b/cmd/service/main.go index 26ca218..dc55834 100644 --- a/cmd/service/main.go +++ b/cmd/service/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "os/signal" "syscall" @@ -10,6 +11,8 @@ import ( "github.com/vocdoni/saas-backend/account" "github.com/vocdoni/saas-backend/api" "github.com/vocdoni/saas-backend/db" + "github.com/vocdoni/saas-backend/notifications/sendgrid" + "github.com/vocdoni/saas-backend/notifications/twilio" "go.vocdoni.io/dvote/apiclient" "go.vocdoni.io/dvote/log" ) @@ -25,6 +28,12 @@ func main() { flag.StringP("vocdoniApi", "v", "https://api-dev.vocdoni.net/v2", "vocdoni node remote API URL") flag.StringP("privateKey", "k", "", "private key for the Vocdoni account") flag.BoolP("fullTransparentMode", "a", false, "allow all transactions and do not modify any of them") + flag.String("sendgridAPIKey", "", "SendGrid API key") + flag.String("sendgridFromAddress", "", "SendGrid from address") + flag.String("sendgridFromName", "Vocdoni", "SendGrid from name") + flag.String("twilioAccountSid", "", "Twilio account SID") + flag.String("twilioAuthToken", "", "Twilio auth token") + flag.String("twilioFromNumber", "", "Twilio from number") // parse flags flag.Parse() // initialize Viper @@ -43,6 +52,14 @@ func main() { } mongoURL := viper.GetString("mongoURL") mongoDB := viper.GetString("mongoDB") + // mail vars + sendgridAPIKey := viper.GetString("sendgridAPIKey") + sendgridFromAddress := viper.GetString("sendgridFromAddress") + sendgridFromName := viper.GetString("sendgridFromName") + // sms vars + twilioAccountSid := viper.GetString("twilioAccountSid") + twilioAuthToken := viper.GetString("twilioAuthToken") + twilioFromNumber := viper.GetString("twilioFromNumber") // initialize the MongoDB database database, err := db.New(mongoURL, mongoDB) if err != nil { @@ -66,8 +83,8 @@ func main() { log.Fatal(err) } log.Infow("API client created", "endpoint", apiEndpoint, "chainID", apiClient.ChainID()) - // create the local API server - api.New(&api.APIConfig{ + // init the API configuration + apiConf := &api.APIConfig{ Host: host, Port: port, Secret: secret, @@ -75,9 +92,37 @@ func main() { Client: apiClient, Account: acc, FullTransparentMode: fullTransparentMode, - }).Start() - // wait forever, as the server is running in a goroutine + } + // create email notifications service if the required parameters are set and + // include it in the API configuration + if sendgridAPIKey != "" && sendgridFromAddress != "" && sendgridFromName != "" { + apiConf.MailService = new(sendgrid.SendGridEmail) + if err := apiConf.MailService.Init(&sendgrid.SendGridConfig{ + FromName: sendgridFromName, + FromAddress: sendgridFromAddress, + APIKey: sendgridAPIKey, + }); err != nil { + log.Fatalf("could not create the email service: %v", err) + } + log.Infow("email service created", "from", fmt.Sprintf("%s <%s>", sendgridFromName, sendgridFromAddress)) + } + // create SMS notifications service if the required parameters are set and + // include it in the API configuration + if twilioAccountSid != "" && twilioAuthToken != "" && twilioFromNumber != "" { + apiConf.SMSService = new(twilio.TwilioSMS) + if err := apiConf.SMSService.Init(&twilio.TwilioConfig{ + AccountSid: twilioAccountSid, + AuthToken: twilioAuthToken, + FromNumber: twilioFromNumber, + }); err != nil { + log.Fatalf("could not create the SMS service: %v", err) + } + log.Infow("SMS service created", "from", twilioFromNumber) + } + // create the local API server + api.New(apiConf).Start() log.Infow("server started", "host", host, "port", port) + // wait forever, as the server is running in a goroutine c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) <-c diff --git a/db/const.go b/db/const.go index bcbe36c..087a691 100644 --- a/db/const.go +++ b/db/const.go @@ -8,6 +8,9 @@ const ( // organization types CompanyType OrganizationType = "company" CommunityType OrganizationType = "community" + // verification code types + CodeTypeAccountVerification CodeType = "account" + CodeTypePasswordReset CodeType = "password" ) // writableRoles is a map that contains if the role is writable or not diff --git a/db/helpers.go b/db/helpers.go index 3e83224..3708b2d 100644 --- a/db/helpers.go +++ b/db/helpers.go @@ -35,7 +35,6 @@ func (ms *MongoStorage) initCollections(database string) error { // if the collection doesn't exist, create it if alreadyCreated { if validator, ok := collectionsValidators[name]; ok { - log.Debugw("updating collection with validator", "collection", name) err := ms.client.Database(database).RunCommand(ctx, bson.D{ {Key: "collMod", Value: name}, {Key: "validator", Value: validator}, @@ -48,9 +47,7 @@ func (ms *MongoStorage) initCollections(database string) error { // if the collection has a validator create it with it opts := options.CreateCollection() if validator, ok := collectionsValidators[name]; ok { - log.Debugw("creating collection with validator", "collection", name) opts = opts.SetValidator(validator).SetValidationLevel("strict").SetValidationAction("error") - } // create the collection if err := ms.client.Database(database).CreateCollection(ctx, name, opts); err != nil { @@ -66,7 +63,11 @@ func (ms *MongoStorage) initCollections(database string) error { } // organizations collection if ms.organizations, err = getCollection("organizations"); err != nil { - return nil + return err + } + // verifications collection + if ms.verifications, err = getCollection("verifications"); err != nil { + return err } return nil } @@ -104,8 +105,7 @@ func (ms *MongoStorage) collectionNames(ctx context.Context, database string) ([ func (ms *MongoStorage) createIndexes() error { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - - // Create an index for the 'email' field on users + // create an index for the 'email' field on users userEmailIndex := mongo.IndexModel{ Keys: bson.D{{Key: "email", Value: 1}}, // 1 for ascending order Options: options.Index().SetUnique(true), @@ -113,8 +113,7 @@ func (ms *MongoStorage) createIndexes() error { if _, err := ms.users.Indexes().CreateOne(ctx, userEmailIndex); err != nil { return fmt.Errorf("failed to create index on addresses for users: %w", err) } - - // Create an index for the 'name' field on organizations (must be unique) + // create an index for the 'name' field on organizations (must be unique) organizationNameIndex := mongo.IndexModel{ Keys: bson.D{{Key: "name", Value: 1}}, // 1 for ascending order Options: options.Index().SetUnique(true), @@ -122,7 +121,14 @@ func (ms *MongoStorage) createIndexes() error { if _, err := ms.organizations.Indexes().CreateOne(ctx, organizationNameIndex); err != nil { return fmt.Errorf("failed to create index on name for organizations: %w", err) } - + // create an index for the 'code' field on user verifications (must be unique) + verificationCodeIndex := mongo.IndexModel{ + Keys: bson.D{{Key: "code", Value: 1}}, // 1 for ascending order + Options: options.Index().SetUnique(true), + } + if _, err := ms.verifications.Indexes().CreateOne(ctx, verificationCodeIndex); err != nil { + return fmt.Errorf("failed to create index on code for verifications: %w", err) + } return nil } @@ -140,7 +146,7 @@ func dynamicUpdateDocument(item interface{}, alwaysUpdateTags []string) (bson.M, } update := bson.M{} typ := val.Type() - // Create a map for quick lookup + // create a map for quick lookup alwaysUpdateMap := make(map[string]bool, len(alwaysUpdateTags)) for _, tag := range alwaysUpdateTags { alwaysUpdateMap[tag] = true @@ -155,8 +161,7 @@ func dynamicUpdateDocument(item interface{}, alwaysUpdateTags []string) (bson.M, if tag == "" || tag == "-" || tag == "_id" { continue } - - // Check if the field should always be updated or is not the zero value + // check if the field should always be updated or is not the zero value _, alwaysUpdate := alwaysUpdateMap[tag] if alwaysUpdate || !reflect.DeepEqual(field.Interface(), reflect.Zero(field.Type()).Interface()) { update[tag] = field.Interface() diff --git a/db/mongo.go b/db/mongo.go index dd1ea67..d81dc1f 100644 --- a/db/mongo.go +++ b/db/mongo.go @@ -22,6 +22,7 @@ type MongoStorage struct { keysLock sync.RWMutex users *mongo.Collection + verifications *mongo.Collection organizations *mongo.Collection } diff --git a/db/mongo_types.go b/db/mongo_types.go index ae2abfd..1fc22a6 100644 --- a/db/mongo_types.go +++ b/db/mongo_types.go @@ -4,6 +4,10 @@ type UserCollection struct { Users []User `json:"users" bson:"users"` } +type UserVerifications struct { + Verifications []UserVerification `json:"verifications" bson:"verifications"` +} + type OrganizationCollection struct { Organizations []Organization `json:"organizations" bson:"organizations"` } diff --git a/db/organizations_test.go b/db/organizations_test.go index 4874b04..cc4f761 100644 --- a/db/organizations_test.go +++ b/db/organizations_test.go @@ -87,10 +87,11 @@ func TestSetOrganization(t *testing.T) { Creator: testUserEmail, }), qt.IsNotNil) // register the creator and retry to create the organization - c.Assert(db.SetUser(&User{ + _, err = db.SetUser(&User{ Email: testUserEmail, Password: testUserPass, - }), qt.IsNil) + }) + c.Assert(err, qt.IsNil) c.Assert(db.SetOrganization(&Organization{ Address: newOrgAddress, Name: newOrgName, @@ -135,10 +136,11 @@ func TestReplaceCreatorEmail(t *testing.T) { // create a new organization with a creator address := "orgToReplaceCreator" name := "Organization to replace creator" - c.Assert(db.SetUser(&User{ + _, err := db.SetUser(&User{ Email: testUserEmail, Password: testUserPass, - }), qt.IsNil) + }) + c.Assert(err, qt.IsNil) c.Assert(db.SetOrganization(&Organization{ Address: address, Name: name, @@ -171,16 +173,17 @@ func TestOrganizationsMembers(t *testing.T) { // create a new organization with a creator address := "orgToReplaceCreator" name := "Organization to replace creator" - c.Assert(db.SetUser(&User{ + _, err := db.SetUser(&User{ Email: testUserEmail, Password: testUserPass, - }), qt.IsNil) + }) + c.Assert(err, qt.IsNil) c.Assert(db.SetOrganization(&Organization{ Address: address, Name: name, Creator: testUserEmail, }), qt.IsNil) - _, _, err := db.Organization(address, false) + _, _, err = db.Organization(address, false) c.Assert(err, qt.IsNil) // get the organization members members, err := db.OrganizationsMembers(address) diff --git a/db/types.go b/db/types.go index 29dd966..48a152b 100644 --- a/db/types.go +++ b/db/types.go @@ -5,10 +5,20 @@ import "time" type User struct { ID uint64 `json:"id" bson:"_id"` Email string `json:"email" bson:"email"` + Phone string `json:"phone" bson:"phone"` Password string `json:"password" bson:"password"` FirstName string `json:"firstName" bson:"firstName"` LastName string `json:"lastName" bson:"lastName"` Organizations []OrganizationMember `json:"organizations" bson:"organizations"` + Verified bool `json:"verified" bson:"verified"` +} + +type CodeType string + +type UserVerification struct { + ID uint64 `json:"id" bson:"_id"` + Code string `json:"code" bson:"code"` + Type CodeType `json:"type" bson:"type"` } func (u *User) HasRoleFor(address string, role UserRole) bool { diff --git a/db/users.go b/db/users.go index 4346e8e..eb8d7d4 100644 --- a/db/users.go +++ b/db/users.go @@ -58,17 +58,9 @@ func (ms *MongoStorage) addOrganizationToUser(ctx context.Context, userEmail, ad return nil } -// UserByEmail method returns the user with the given email. If the user doesn't -// exist, it returns a specific error. If other errors occur, it returns the -// error. -func (ms *MongoStorage) UserByEmail(email string) (*User, error) { - ms.keysLock.RLock() - defer ms.keysLock.RUnlock() - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - result := ms.users.FindOne(ctx, bson.M{"email": email}) +func (ms *MongoStorage) user(ctx context.Context, id uint64) (*User, error) { + // find the user in the database + result := ms.users.FindOne(ctx, bson.M{"_id": id}) user := &User{} if err := result.Decode(user); err != nil { if err == mongo.ErrNoDocuments { @@ -87,8 +79,20 @@ func (ms *MongoStorage) User(id uint64) (*User, error) { // create a context with a timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - // find the user in the database - result := ms.users.FindOne(ctx, bson.M{"_id": id}) + return ms.user(ctx, id) +} + +// UserByEmail method returns the user with the given email. If the user doesn't +// exist, it returns a specific error. If other errors occur, it returns the +// error. +func (ms *MongoStorage) UserByEmail(email string) (*User, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ms.users.FindOne(ctx, bson.M{"email": email}) user := &User{} if err := result.Decode(user); err != nil { if err == mongo.ErrNoDocuments { @@ -102,7 +106,7 @@ func (ms *MongoStorage) User(id uint64) (*User, error) { // SetUser method creates or updates the user in the database. If the user // already exists, it updates the fields that have changed. If the user doesn't // exist, it creates it. If an error occurs, it returns the error. -func (ms *MongoStorage) SetUser(user *User) error { +func (ms *MongoStorage) SetUser(user *User) (uint64, error) { ms.keysLock.Lock() defer ms.keysLock.Unlock() // create a context with a timeout @@ -111,7 +115,7 @@ func (ms *MongoStorage) SetUser(user *User) error { // get the next available user ID nextID, err := ms.nextUserID(ctx) if err != nil { - return err + return 0, err } // if the user provided doesn't have organizations, create an empty slice if user.Organizations == nil { @@ -120,25 +124,25 @@ func (ms *MongoStorage) SetUser(user *User) error { // check if the user exists or needs to be created if user.ID > 0 { if user.ID >= nextID { - return ErrInvalidData + return 0, ErrInvalidData } // if the user exists, update it with the new data updateDoc, err := dynamicUpdateDocument(user, nil) if err != nil { - return err + return 0, err } _, err = ms.users.UpdateOne(ctx, bson.M{"_id": user.ID}, updateDoc) if err != nil { - return err + return 0, err } } else { // if the user doesn't exist, create it setting the ID first user.ID = nextID if _, err := ms.users.InsertOne(ctx, user); err != nil { - return err + return 0, err } } - return nil + return user.ID, nil } // DelUser method deletes the user from the database. If an error occurs, it @@ -162,6 +166,27 @@ func (ms *MongoStorage) DelUser(user *User) error { return err } +// VerifyUserAccount method verifies the user provided, modifying the user to +// mark as verified and removing the verification code. If an error occurs, it +// returns the error. +func (ms *MongoStorage) VerifyUserAccount(user *User) error { + ms.keysLock.Lock() + defer ms.keysLock.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // try to get the user to ensure it exists + if _, err := ms.user(ctx, user.ID); err != nil { + return err + } + // update the user to mark as verified + filter := bson.M{"_id": user.ID} + if _, err := ms.users.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"verified": true}}); err != nil { + return err + } + // remove the verification code + return ms.delVerificationCode(ctx, user.ID, CodeTypeAccountVerification) +} + // IsMemberOf method checks if the user with the given email is a member of the // organization with the given address and role. If the user is a member, it // returns true. If the user is not a member, it returns false. If an error diff --git a/db/users_test.go b/db/users_test.go index f73493e..9c6832a 100644 --- a/db/users_test.go +++ b/db/users_test.go @@ -25,12 +25,13 @@ func TestUserByEmail(t *testing.T) { c.Assert(user, qt.IsNil) c.Assert(err, qt.Equals, ErrNotFound) // create a new user with the email - c.Assert(db.SetUser(&User{ + _, err = db.SetUser(&User{ Email: testUserEmail, Password: testUserPass, FirstName: testUserFirstName, LastName: testUserLastName, - }), qt.IsNil) + }) + c.Assert(err, qt.IsNil) // test found user user, err = db.UserByEmail(testUserEmail) c.Assert(err, qt.IsNil) @@ -39,6 +40,7 @@ func TestUserByEmail(t *testing.T) { c.Assert(user.Password, qt.Equals, testUserPass) c.Assert(user.FirstName, qt.Equals, testUserFirstName) c.Assert(user.LastName, qt.Equals, testUserLastName) + c.Assert(user.Verified, qt.IsFalse) } func TestUser(t *testing.T) { @@ -54,12 +56,13 @@ func TestUser(t *testing.T) { c.Assert(user, qt.IsNil) c.Assert(err, qt.Equals, ErrNotFound) // create a new user with the ID - c.Assert(db.SetUser(&User{ + _, err = db.SetUser(&User{ Email: testUserEmail, Password: testUserPass, FirstName: testUserFirstName, LastName: testUserLastName, - }), qt.IsNil) + }) + c.Assert(err, qt.IsNil) // get the user ID user, err = db.UserByEmail(testUserEmail) c.Assert(err, qt.IsNil) @@ -72,6 +75,7 @@ func TestUser(t *testing.T) { c.Assert(user.Password, qt.Equals, testUserPass) c.Assert(user.FirstName, qt.Equals, testUserFirstName) c.Assert(user.LastName, qt.Equals, testUserLastName) + c.Assert(user.Verified, qt.IsFalse) } func TestSetUser(t *testing.T) { @@ -88,27 +92,32 @@ func TestSetUser(t *testing.T) { FirstName: testUserFirstName, LastName: testUserLastName, } - c.Assert(db.SetUser(user), qt.IsNotNil) + _, err := db.SetUser(user) + c.Assert(err, qt.IsNotNil) // trying to update a non existing user user.ID = 100 - c.Assert(db.SetUser(user), qt.Equals, ErrInvalidData) + _, err = db.SetUser(user) + c.Assert(err, qt.Equals, ErrInvalidData) // unset the ID to create a new user user.ID = 0 user.Email = testUserEmail // create a new user - c.Assert(db.SetUser(user), qt.IsNil) + _, err = db.SetUser(user) + c.Assert(err, qt.IsNil) // update the user newFirstName := "New User" user.FirstName = newFirstName - c.Assert(db.SetUser(user), qt.IsNil) + _, err = db.SetUser(user) + c.Assert(err, qt.IsNil) // get the user - user, err := db.UserByEmail(user.Email) + user, err = db.UserByEmail(user.Email) c.Assert(err, qt.IsNil) c.Assert(user, qt.Not(qt.IsNil)) c.Assert(user.Email, qt.Equals, testUserEmail) c.Assert(user.Password, qt.Equals, testUserPass) c.Assert(user.FirstName, qt.Equals, newFirstName) c.Assert(user.LastName, qt.Equals, testUserLastName) + c.Assert(user.Verified, qt.IsFalse) } func TestDelUser(t *testing.T) { @@ -125,9 +134,10 @@ func TestDelUser(t *testing.T) { FirstName: testUserFirstName, LastName: testUserLastName, } - c.Assert(db.SetUser(user), qt.IsNil) + _, err := db.SetUser(user) + c.Assert(err, qt.IsNil) // get the user - user, err := db.UserByEmail(user.Email) + user, err = db.UserByEmail(user.Email) c.Assert(err, qt.IsNil) c.Assert(user, qt.Not(qt.IsNil)) // delete the user by ID removing the email @@ -139,7 +149,8 @@ func TestDelUser(t *testing.T) { c.Assert(err, qt.Equals, ErrNotFound) // insert the user again with the same email but no ID user.ID = 0 - c.Assert(db.SetUser(user), qt.IsNil) + _, err = db.SetUser(user) + c.Assert(err, qt.IsNil) // delete the user by email c.Assert(db.DelUser(user), qt.IsNil) // try to get the user @@ -166,7 +177,8 @@ func TestIsMemberOf(t *testing.T) { {Address: "viewOrg", Role: ViewerRole}, }, } - c.Assert(db.SetUser(user), qt.IsNil) + _, err := db.SetUser(user) + c.Assert(err, qt.IsNil) // test the user is member of the organizations for _, org := range user.Organizations { success, err := db.IsMemberOf(user.Email, org.Address, org.Role) diff --git a/db/verifications.go b/db/verifications.go new file mode 100644 index 0000000..df02d01 --- /dev/null +++ b/db/verifications.go @@ -0,0 +1,84 @@ +package db + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// UserByVerificationCode method returns the user with the given verification +// code. If the user or the verification code doesn't exist, it returns a +// specific error. If other errors occur, it returns the error. It checks the +// user verification code in the verifications collection and returns the user +// with the ID associated with the verification code. +func (ms *MongoStorage) UserByVerificationCode(code string, t CodeType) (*User, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ms.verifications.FindOne(ctx, bson.M{"code": code, "type": t}) + verification := &UserVerification{} + if err := result.Decode(verification); err != nil { + if err == mongo.ErrNoDocuments { + return nil, ErrNotFound + } + return nil, err + } + return ms.user(ctx, verification.ID) +} + +// UserVerificationCode returns the verification code for the user provided. If +// the user has not a verification code, it returns an specific error, if other +// error occurs, it returns the error. +func (ms *MongoStorage) UserVerificationCode(user *User, t CodeType) (string, error) { + ms.keysLock.RLock() + defer ms.keysLock.RUnlock() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := ms.verifications.FindOne(ctx, bson.M{"_id": user.ID, "type": t}) + verification := &UserVerification{} + if err := result.Decode(verification); err != nil { + if err == mongo.ErrNoDocuments { + return "", ErrNotFound + } + return "", err + } + return verification.Code, nil +} + +// SetVerificationCode method sets the verification code for the user provided. +// If the user already has a verification code, it updates it. If an error +// occurs, it returns the error. +func (ms *MongoStorage) SetVerificationCode(user *User, code string, t CodeType) error { + ms.keysLock.Lock() + defer ms.keysLock.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // try to get the user to ensure it exists + if _, err := ms.user(ctx, user.ID); err != nil { + return err + } + // insert the verification code for the user provided + filter := bson.M{"_id": user.ID} + verification := &UserVerification{ + ID: user.ID, + Code: code, + Type: t, + } + opts := options.Replace().SetUpsert(true) + _, err := ms.verifications.ReplaceOne(ctx, filter, verification, opts) + return err +} + +func (ms *MongoStorage) delVerificationCode(ctx context.Context, id uint64, t CodeType) error { + // delete the verification code for the user provided + _, err := ms.verifications.DeleteOne(ctx, bson.M{"_id": id, "type": t}) + return err +} diff --git a/db/verifications_test.go b/db/verifications_test.go new file mode 100644 index 0000000..8796cf8 --- /dev/null +++ b/db/verifications_test.go @@ -0,0 +1,101 @@ +package db + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestUserVerificationCode(t *testing.T) { + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + c := qt.New(t) + + userID, err := db.SetUser(&User{ + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, + }) + c.Assert(err, qt.IsNil) + + _, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + c.Assert(err, qt.Equals, ErrNotFound) + + testCode := "testCode" + c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeAccountVerification), qt.IsNil) + + code, err := db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + c.Assert(err, qt.IsNil) + c.Assert(code, qt.Equals, testCode) + + c.Assert(db.VerifyUserAccount(&User{ID: userID}), qt.IsNil) + _, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + c.Assert(err, qt.Equals, ErrNotFound) +} + +func TestSetVerificationCode(t *testing.T) { + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + c := qt.New(t) + + nonExistingUserID := uint64(100) + c.Assert(db.SetVerificationCode(&User{ID: nonExistingUserID}, "testCode", CodeTypeAccountVerification), qt.Equals, ErrNotFound) + + userID, err := db.SetUser(&User{ + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, + }) + c.Assert(err, qt.IsNil) + + testCode := "testCode" + c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeAccountVerification), qt.IsNil) + + code, err := db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + c.Assert(err, qt.IsNil) + c.Assert(code, qt.Equals, testCode) + + testCode = "testCode2" + c.Assert(db.SetVerificationCode(&User{ID: userID}, testCode, CodeTypeAccountVerification), qt.IsNil) + + code, err = db.UserVerificationCode(&User{ID: userID}, CodeTypeAccountVerification) + c.Assert(err, qt.IsNil) + c.Assert(code, qt.Equals, testCode) +} + +func TestVerifyUser(t *testing.T) { + defer func() { + if err := db.Reset(); err != nil { + t.Error(err) + } + }() + c := qt.New(t) + + nonExistingUserID := uint64(100) + c.Assert(db.VerifyUserAccount(&User{ID: nonExistingUserID}), qt.Equals, ErrNotFound) + + userID, err := db.SetUser(&User{ + Email: testUserEmail, + Password: testUserPass, + FirstName: testUserFirstName, + LastName: testUserLastName, + }) + c.Assert(err, qt.IsNil) + + user, err := db.User(userID) + c.Assert(err, qt.IsNil) + c.Assert(user.Verified, qt.IsFalse) + + c.Assert(db.VerifyUserAccount(&User{ID: userID}), qt.IsNil) + user, err = db.User(userID) + c.Assert(err, qt.IsNil) + c.Assert(user.Verified, qt.IsTrue) +} diff --git a/docker-compose.yml b/docker-compose.yml index 04bc68f..101ba7c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,8 +2,11 @@ name: saas-backend services: api: + platform: linux/amd64 env_file: - .env + environment: + - VOCDONI_MONGOURL=mongodb://root:vocdoni@mongo:27017/ build: context: ./ ports: diff --git a/example.env b/example.env new file mode 100644 index 0000000..ccaa452 --- /dev/null +++ b/example.env @@ -0,0 +1,5 @@ +VOCDONI_PORT=8080 +VOCDONI_SECRET=supersecret +VOCDONI_PRIVATEKEY=vochain-private-key +VOCDONI_SENDGRIDAPIKEY=SG.1234567890 +VOCDONI_SENDGRIDFROMADDRESS=admin@email.com \ No newline at end of file diff --git a/go.mod b/go.mod index b074ce5..26be066 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,11 @@ require ( github.com/go-chi/cors v1.2.1 github.com/go-chi/jwtauth/v5 v5.3.1 github.com/lestrrat-go/jwx/v2 v2.0.20 + github.com/sendgrid/sendgrid-go v3.16.0+incompatible github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 github.com/testcontainers/testcontainers-go v0.32.0 + github.com/twilio/twilio-go v1.23.0 go.mongodb.org/mongo-driver v1.14.0 go.vocdoni.io/dvote v1.10.2-0.20240726114655-b510ac8a7e42 go.vocdoni.io/proto v1.15.8 @@ -105,6 +107,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/glog v1.1.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/golang/mock v1.6.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect github.com/google/btree v1.1.2 // indirect @@ -294,6 +297,7 @@ require ( github.com/samber/lo v1.39.0 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/sethvargo/go-retry v0.2.4 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shirou/gopsutil/v3 v3.23.12 // indirect diff --git a/go.sum b/go.sum index d2155b0..0ac0384 100644 --- a/go.sum +++ b/go.sum @@ -142,6 +142,7 @@ github.com/aws/aws-sdk-go-v2/service/route53 v1.1.1/go.mod h1:rLiOUrPLW/Er5kRcQ7 github.com/aws/aws-sdk-go-v2/service/sso v1.1.1/go.mod h1:SuZJxklHxLAXgLTc1iFXbEWkXs7QRTQpCLGaKIprQW0= github.com/aws/aws-sdk-go-v2/service/sts v1.1.1/go.mod h1:Wi0EBZwiz/K44YliU0EKxqTCJGUfYTWXrrBwkq736bM= github.com/aws/smithy-go v1.1.0/go.mod h1:EzMw8dbp/YJL4A5/sbhGddag+NPT7q084agLbB9LgIw= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= @@ -473,6 +474,8 @@ github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -494,6 +497,8 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -950,6 +955,8 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= github.com/linxGnu/grocksdb v1.8.6 h1:O7I6SIGPrypf3f/gmrrLUBQDKfO8uOoYdWf4gLS06tc= github.com/linxGnu/grocksdb v1.8.6/go.mod h1:xZCIb5Muw+nhbDK4Y5UJuOrin5MceOuiXkVUR7vp4WY= +github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= +github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= @@ -1122,6 +1129,7 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= @@ -1371,6 +1379,10 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/kafka-go v0.1.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= github.com/segmentio/kafka-go v0.2.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfPOCvTvk+EJo= +github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= +github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBUpXyTcTXy/Rl9N9aZ/Qw= +github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sethvargo/go-retry v0.2.4 h1:T+jHEQy/zKJf5s95UkguisicE0zuF9y7+/vgz08Ocec= github.com/sethvargo/go-retry v0.2.4/go.mod h1:1afjQuvh7s4gflMObvjLPaWgluLLyhA1wmVZ6KLpICw= @@ -1502,6 +1514,8 @@ github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9f github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c h1:u6SKchux2yDvFQnDHS3lPnIRmfVJ5Sxy3ao2SIdysLQ= github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c/go.mod h1:hzIxponao9Kjc7aWznkXaL4U4TWaDSs8zcsY4Ka08nM= +github.com/twilio/twilio-go v1.23.0 h1:cIJD6XnVuRqnMVp8LswoOTEi4/JK9WctOTUvUR2gLf0= +github.com/twilio/twilio-go v1.23.0/go.mod h1:zRkMjudW7v7MqQ3cWNZmSoZJ7EBjPZ4OpNh2zm7Q6ko= github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= github.com/tyler-smith/go-bip39 v1.0.2/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= @@ -2030,6 +2044,7 @@ golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= @@ -2165,6 +2180,7 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= diff --git a/internal/utils.go b/internal/utils.go new file mode 100644 index 0000000..e243fce --- /dev/null +++ b/internal/utils.go @@ -0,0 +1,48 @@ +package internal + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" +) + +var regexpEmail = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + +// ValidEmail helper function allows to validate an email address. +func ValidEmail(email string) bool { + return regexpEmail.MatchString(email) +} + +// RandomBytes helper function allows to generate a random byte slice of n bytes. +func RandomBytes(n int) []byte { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + panic(err) + } + return b +} + +// RandomHex helper function allows to generate a random hex string of n bytes. +func RandomHex(n int) string { + return fmt.Sprintf("%x", RandomBytes(n)) +} + +// HashPassword helper function allows to hash a password using a salt. +func HashPassword(salt, password string) []byte { + return sha256.New().Sum([]byte(salt + password)) +} + +// HexHashPassword helper function allows to hash a password using a salt and +// return the result as a hex string. +func HexHashPassword(salt, password string) string { + return hex.EncodeToString(HashPassword(salt, password)) +} + +// HashVerificationCode helper function allows to hash a verification code +// associated to the email of the user that requested it. +func HashVerificationCode(userEmail, code string) string { + return hex.EncodeToString(sha256.New().Sum([]byte(userEmail + code))) +} diff --git a/notifications/notifications.go b/notifications/notifications.go new file mode 100644 index 0000000..430d7e7 --- /dev/null +++ b/notifications/notifications.go @@ -0,0 +1,16 @@ +package notifications + +import "context" + +type Notification struct { + ToName string + ToAddress string + ToNumber string + Subject string + Body string +} + +type NotificationService interface { + Init(conf any) error + SendNotification(context.Context, *Notification) error +} diff --git a/notifications/sendgrid/email.go b/notifications/sendgrid/email.go new file mode 100644 index 0000000..83a932a --- /dev/null +++ b/notifications/sendgrid/email.go @@ -0,0 +1,52 @@ +package sendgrid + +import ( + "context" + "fmt" + + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" + "github.com/vocdoni/saas-backend/notifications" +) + +type SendGridConfig struct { + FromName string + FromAddress string + APIKey string +} + +type SendGridEmail struct { + config *SendGridConfig + client *sendgrid.Client +} + +func (sg *SendGridEmail) Init(rawConfig any) error { + // parse configuration + config, ok := rawConfig.(*SendGridConfig) + if !ok { + return fmt.Errorf("invalid SendGrid configuration") + } + // set configuration in struct + sg.config = config + // init SendGrid client + sg.client = sendgrid.NewSendClient(sg.config.APIKey) + return nil +} + +func (sg *SendGridEmail) SendNotification(ctx context.Context, notification *notifications.Notification) error { + // create from and to email + from := mail.NewEmail(sg.config.FromName, sg.config.FromAddress) + to := mail.NewEmail(notification.ToName, notification.ToAddress) + // create email with notification data + message := mail.NewSingleEmail(from, notification.Subject, to, notification.Body, notification.Body) + // send the email + res, err := sg.client.SendWithContext(ctx, message) + if err != nil { + return fmt.Errorf("could not send email: %v", err) + } + // check the response status code, it should be 2xx + if res.StatusCode/100 != 2 { + return fmt.Errorf("could not send email: %v", res.Body) + } + return nil +} diff --git a/notifications/testmail/mail.go b/notifications/testmail/mail.go new file mode 100644 index 0000000..13b1e4b --- /dev/null +++ b/notifications/testmail/mail.go @@ -0,0 +1,97 @@ +package testmail + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/smtp" + + "github.com/vocdoni/saas-backend/notifications" +) + +type TestMailConfig struct { + FromAddress string + SMTPUser string + SMTPPassword string + Host string + SMTPPort int + APIPort int +} + +type TestMail struct { + config *TestMailConfig +} + +func (tm *TestMail) Init(rawConfig any) error { + config, ok := rawConfig.(*TestMailConfig) + if !ok { + return fmt.Errorf("invalid TestMail configuration") + } + tm.config = config + return nil +} + +func (tm *TestMail) SendNotification(_ context.Context, notification *notifications.Notification) error { + auth := smtp.PlainAuth("", tm.config.SMTPUser, tm.config.SMTPPassword, tm.config.Host) + smtpAddr := fmt.Sprintf("%s:%d", tm.config.Host, tm.config.SMTPPort) + msg := []byte("To: " + notification.ToAddress + "\r\n" + + "Subject: " + notification.Subject + "\r\n" + + "\r\n" + + notification.Body + "\r\n") + return smtp.SendMail(smtpAddr, auth, tm.config.FromAddress, []string{notification.ToAddress}, msg) +} + +func (tm *TestMail) clear() error { + clearEndpoint := fmt.Sprintf("http://%s:%d/api/v1/messages", tm.config.Host, tm.config.APIPort) + req, err := http.NewRequest("DELETE", clearEndpoint, nil) + if err != nil { + return fmt.Errorf("could not create request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("could not send request: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} + +func (tm *TestMail) FindEmail(ctx context.Context, to string) (string, error) { + searchEndpoint := fmt.Sprintf("http://%s:%d/api/v2/search?kind=to&query=%s", tm.config.Host, tm.config.APIPort, to) + req, err := http.NewRequestWithContext(ctx, "GET", searchEndpoint, nil) + if err != nil { + return "", fmt.Errorf("could not create request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("could not send request: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + type mailResponse struct { + Items []struct { + Content struct { + Body string `json:"Body"` + } `json:"Content"` + } `json:"items"` + } + mailResults := mailResponse{} + if err := json.NewDecoder(resp.Body).Decode(&mailResults); err != nil { + return "", fmt.Errorf("could not decode response: %v", err) + } + if len(mailResults.Items) == 0 { + return "", io.EOF + } + return mailResults.Items[0].Content.Body, tm.clear() +} diff --git a/notifications/twilio/sms.go b/notifications/twilio/sms.go new file mode 100644 index 0000000..d88ae84 --- /dev/null +++ b/notifications/twilio/sms.go @@ -0,0 +1,70 @@ +package twilio + +import ( + "context" + "fmt" + "os" + + t "github.com/twilio/twilio-go" + api "github.com/twilio/twilio-go/rest/api/v2010" + "github.com/vocdoni/saas-backend/notifications" +) + +const ( + AccountSidEnv = "TWILIO_ACCOUNT_SID" + AuthTokenEnv = "TWILIO_AUTH_TOKEN" +) + +type TwilioConfig struct { + AccountSid string + AuthToken string + FromNumber string +} + +type TwilioSMS struct { + config *TwilioConfig + client *t.RestClient +} + +func (tsms *TwilioSMS) Init(rawConfig any) error { + // parse configuration + config, ok := rawConfig.(*TwilioConfig) + if !ok { + return fmt.Errorf("invalid Twilio configuration") + } + // set configuration in struct + tsms.config = config + // set account SID and auth token as environment variables + if err := os.Setenv(AccountSidEnv, tsms.config.AccountSid); err != nil { + return err + } + if err := os.Setenv(AuthTokenEnv, tsms.config.AuthToken); err != nil { + return err + } + // init Twilio REST client + tsms.client = t.NewRestClient() + return nil +} + +func (tsms *TwilioSMS) SendNotification(ctx context.Context, notification *notifications.Notification) error { + // create message with configured sender number and notification data + params := &api.CreateMessageParams{} + params.SetTo(notification.ToNumber) + params.SetFrom(tsms.config.FromNumber) + params.SetBody(notification.Body) + // create a channel to handle errors + errCh := make(chan error, 1) + go func() { + // send the message + _, err := tsms.client.Api.CreateMessage(params) + errCh <- err + close(errCh) + }() + // wait for the message to be sent or the context to be done + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errCh: + return err + } +} diff --git a/test/mail.go b/test/mail.go new file mode 100644 index 0000000..d0ada4a --- /dev/null +++ b/test/mail.go @@ -0,0 +1,28 @@ +package test + +import ( + "context" + "fmt" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + MailSMTPPort = "1025" + MailAPIPort = "8025" +) + +func StartMailService(ctx context.Context) (testcontainers.Container, error) { + smtpPort := fmt.Sprintf("%s/tcp", MailSMTPPort) + apiPort := fmt.Sprintf("%s/tcp", MailAPIPort) + return testcontainers.GenericContainer(ctx, + testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: "mailhog/mailhog", + ExposedPorts: []string{smtpPort, apiPort}, + WaitingFor: wait.ForListeningPort(MailSMTPPort), + }, + Started: true, + }) +}