Skip to content

Commit

Permalink
feature: notifications (#9)
Browse files Browse the repository at this point in the history
* wrappers to email and sms notifications via sendgrid and twilio
* db methods and api endpoints for email verification
* db and api tests
* allowing to verify users with no code if email service is not defined (for testing)
* new endpoints to request a password recovery and reset user password
* api test for recover and reset password
  • Loading branch information
lucasmenendez authored Sep 19, 2024
1 parent c11c238 commit b651ae1
Show file tree
Hide file tree
Showing 33 changed files with 1,216 additions and 127 deletions.
2 changes: 0 additions & 2 deletions .env

This file was deleted.

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.env
48 changes: 32 additions & 16 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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,
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
62 changes: 52 additions & 10 deletions api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -24,17 +25,27 @@ type apiTestCase struct {
}

const (
testSecret = "super-secret"
testEmail = "[email protected]"
testPass = "password123"
testHost = "0.0.0.0"
testPort = 7788
testSecret = "super-secret"
testEmail = "[email protected]"
testPass = "password123"
testFirstName = "test"
testLastName = "user"
testHost = "0.0.0.0"
testPort = 7788

adminEmail = "[email protected]"
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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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
Expand Down
9 changes: 6 additions & 3 deletions api/auth.go
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions api/const.go
Original file line number Diff line number Diff line change
@@ -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: "
)
81 changes: 70 additions & 11 deletions api/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -64,14 +67,6 @@
* **Headers**
* `Authentication: Bearer <user_token>`

* **Response**
```json
{
"token": "<jwt_token>",
"expirity": "2024-08-21T11:26:54.368718+02:00"
}
```

* **Errors**

| HTTP Status | Error code | Message |
Expand Down Expand Up @@ -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": "[email protected]",
"code": "******",
}
```

* **Response**
```json
{
Expand All @@ -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` |

Expand Down Expand Up @@ -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
Expand All @@ -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": "[email protected]",
}
```

* **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": "[email protected]",
"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
Expand All @@ -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
{
Expand Down
Loading

0 comments on commit b651ae1

Please sign in to comment.