Skip to content

Commit

Permalink
feature: expirable verification codes for user account registration (#16
Browse files Browse the repository at this point in the history
)

* limit verification codes by expiration time
* sms expirable verification codes included in the api and database indexes improved
  • Loading branch information
lucasmenendez authored Sep 30, 2024
1 parent 987662a commit ad170ff
Show file tree
Hide file tree
Showing 14 changed files with 364 additions and 69 deletions.
6 changes: 6 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ func (a *API) initRouter() http.Handler {
// verify user
log.Infow("new route", "method", "POST", "path", verifyUserEndpoint)
r.Post(verifyUserEndpoint, a.verifyUserAccountHandler)
// get user verification code information
log.Infow("new route", "method", "GET", "path", verifyUserCodeEndpoint)
r.Get(verifyUserCodeEndpoint, a.userVerificationCodeInfoHandler)
// resend user verification code
log.Infow("new route", "method", "POST", "path", verifyUserCodeEndpoint)
r.Post(verifyUserCodeEndpoint, a.resendUserVerificationCodeHandler)
// request user password recovery
log.Infow("new route", "method", "POST", "path", usersRecoveryPasswordEndpoint)
r.Post(usersRecoveryPasswordEndpoint, a.recoverUserPasswordHandler)
Expand Down
6 changes: 6 additions & 0 deletions api/const.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
package api

import "time"

// VerificationCodeExpiration is the duration of the verification code
// before it is invalidated
var VerificationCodeExpiration = 2 * time.Minute

const (
// VerificationCodeLength is the length of the verification code in bytes
VerificationCodeLength = 3
Expand Down
53 changes: 53 additions & 0 deletions api/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
- [👥 Users](#-users)
- [🙋 Register](#-register)
- [✅ Verify user](#-verify-user)
- [🪪 User verification code info](#-user-verification-code-info)
- [📤 Resend user verification code](#-resend-user-verification-code)
- [🧑‍💻 Get current user info](#-get-current-user-info)
- [💇 Update current user info](#-update-current-user-info)
- [🔏 Update current user password](#-update-current-user-password)
Expand Down Expand Up @@ -225,6 +227,57 @@ This endpoint only returns the addresses of the organizations where the current
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40004` | `malformed JSON body` |
| `400` | `40005` | `invalid user data` |
| `400` | `40015` | `user account already verified` |
| `401` | `40016` | `verification code expired` |
| `500` | `50002` | `internal server error` |

### 🪪 User verification code info

* **Path** `/users/verify/code`
* **Method** `GET`
* **Query params**
* `email`

* **Response**
```json
{
"email": "[email protected]",
"expiration": "2024-09-20T09:02:26.849Z",
"valid": true
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40005` | `invalid user data` |
| `400` | `40015` | `user account already verified` |
| `404` | `40018` | `user not found` |
| `500` | `50002` | `internal server error` |

### 📤 Resend user verification code

* **Path** `/users/verify/code`
* **Method** `POST`
* **Request Body**
```json
{
"email": "[email protected]",
}
```

* **Errors**

| HTTP Status | Error code | Message |
|:---:|:---:|:---|
| `401` | `40001` | `user not authorized` |
| `400` | `40004` | `malformed JSON body` |
| `400` | `40005` | `invalid user data` |
| `400` | `40015` | `user account already verified` |
| `400` | `40017` | `last verification code still valid` |
| `500` | `50002` | `internal server error` |

### 🧑‍💻 Get current user info
Expand Down
4 changes: 4 additions & 0 deletions api/errors_definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ var (
ErrNoOrganizations = Error{Code: 40012, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("this user has not been assigned to any organization")}
ErrInvalidOrganizationData = Error{Code: 40013, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("invalid organization data")}
ErrUserNoVerified = Error{Code: 40014, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("user account not verified")}
ErrUserAlreadyVerified = Error{Code: 40015, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("user account already verified")}
ErrVerificationCodeExpired = Error{Code: 40016, HTTPstatus: http.StatusUnauthorized, Err: fmt.Errorf("verification code expired")}
ErrVerificationCodeValid = Error{Code: 40017, HTTPstatus: http.StatusBadRequest, Err: fmt.Errorf("last verification code still valid")}
ErrUserNotFound = Error{Code: 40018, HTTPstatus: http.StatusNotFound, Err: fmt.Errorf("user not found")}

ErrMarshalingServerJSONFailed = Error{Code: 50001, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("marshaling (server-side) JSON failed")}
ErrGenericInternalServerError = Error{Code: 50002, HTTPstatus: http.StatusInternalServerError, Err: fmt.Errorf("internal server error")}
Expand Down
3 changes: 3 additions & 0 deletions api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const (
usersEndpoint = "/users"
// POST /users/verify to verify the user
verifyUserEndpoint = "/users/verify"
// GET /users/verify/code to get the user verification code information
// POST /users/verify/code to try to resend the user verification code
verifyUserCodeEndpoint = "/users/verify/code"
// GET /users/me to get the current user information
// PUT /users/me to update the current user information
usersMeEndpoint = "/users/me"
Expand Down
8 changes: 5 additions & 3 deletions api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,11 @@ type UserPasswordUpdate struct {

// UserVerificationRequest is the request to verify a user.
type UserVerification struct {
Email string `json:"email"`
Code string `json:"code"`
Phone string `json:"phone"`
Email string `json:"email,omitempty"`
Code string `json:"code,omitempty"`
Phone string `json:"phone,omitempty"`
Expiration time.Time `json:"expiration,omitempty"`
Valid bool `json:"valid"`
}

type UserPasswordReset struct {
Expand Down
174 changes: 165 additions & 9 deletions api/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,18 @@ import (
// 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 {
func (a *API) sendUserCode(ctx context.Context, user *db.User, t 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 {
hashCode := internal.HashVerificationCode(user.Email, code)
exp := time.Now().Add(VerificationCodeExpiration)
if err := a.db.SetVerificationCode(&db.User{ID: user.ID}, hashCode, t, exp); err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
Expand Down Expand Up @@ -128,18 +129,27 @@ func (a *API) registerHandler(w http.ResponseWriter, r *http.Request) {
}

// 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.
// requires the user email and the verification code to be provided. It checks
// if the user has not been verified yet, if the verification code is not
// expired and if the verification code is correct. If all the checks are
// correct, the user account is verified and a new token is generated and sent
// back to the user. If the user is already verified, an error is returned. If
// the verification code is expired, an error is returned. If the verification
// code is incorrect, an error is returned and the number of attempts to verify
// it is increased. If any other error occurs, a generic 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)
// check the email and verification code are not empty
if verification.Email == "" || verification.Code == "" {
ErrInvalidUserData.With("no verification code or email provided").Write(w)
return
}
// get the user information from the database by email
user, err := a.db.UserByEmail(verification.Email)
if err != nil {
if err == db.ErrNotFound {
ErrUnauthorized.Write(w)
Expand All @@ -148,6 +158,33 @@ func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) {
ErrGenericInternalServerError.Write(w)
return
}
// check the user is not already verified
if user.Verified {
ErrUserAlreadyVerified.Write(w)
return
}
// get the verification code from the database
code, err := a.db.UserVerificationCode(user, db.CodeTypeAccountVerification)
if err != nil {
if err != db.ErrNotFound {
log.Warnw("could not get verification code", "error", err)
}
ErrUnauthorized.Write(w)
return
}
// check the verification code is not expired
if code.Expiration.Before(time.Now()) {
ErrVerificationCodeExpired.Write(w)
return
}
// check the verification code is correct
hashCode := internal.HashVerificationCode(verification.Email, verification.Code)
if code.Code != hashCode {
ErrUnauthorized.Write(w)
return
}
// verify the user account if the current verification code is valid and
// matches with the provided one
if err := a.db.VerifyUserAccount(user); err != nil {
ErrGenericInternalServerError.Write(w)
return
Expand All @@ -162,6 +199,125 @@ func (a *API) verifyUserAccountHandler(w http.ResponseWriter, r *http.Request) {
httpWriteJSON(w, res)
}

// userVerificationCodeInfoHandler handles the request to get the verification
// code information of a user. It requires the user email to be provided. It
// returns the user email, the verification code, the phone number, the code
// expiration and if the code is valid (not expired and has not reached the
// maximum number of attempts). If the user is already verified, an error is
// returned. If the user is not found, an error is returned. If the
// verification code is not found, an error is returned. If any other error
// occurs, a generic error is returned.
func (a *API) userVerificationCodeInfoHandler(w http.ResponseWriter, r *http.Request) {
// get the user email or the phone number of the user from the request query
userEmail := r.URL.Query().Get("email")
userPhone := r.URL.Query().Get("phone")
// check the email or the phone number is not empty
if userEmail == "" && userPhone == "" {
ErrInvalidUserData.With("no email or phone number provided").Write(w)
return
}
var err error
var user *db.User
// get the user information from the database by email or phone
if userEmail != "" {
user, err = a.db.UserByEmail(userEmail)
} else {
user, err = a.db.UserByPhone(userPhone)
}
// check the error getting the user information
if err != nil {
if err == db.ErrNotFound {
ErrUserNotFound.Write(w)
return
}
ErrGenericInternalServerError.Write(w)
return
}
// check if the user is already verified
if user.Verified {
ErrUserAlreadyVerified.Write(w)
return
}
// get the verification code from the database
code, err := a.db.UserVerificationCode(user, db.CodeTypeAccountVerification)
if err != nil {
if err != db.ErrNotFound {
log.Warnw("could not get verification code", "error", err)
}
ErrUnauthorized.Write(w)
return
}
// return the verification code information
httpWriteJSON(w, UserVerification{
Email: user.Email,
Phone: user.Phone,
Expiration: code.Expiration,
Valid: code.Expiration.After(time.Now()),
})
}

// resendUserVerificationCodeHandler handles the request to resend the user
// verification code. It requires the user email to be provided. If the user is
// not found, an error is returned. If the user is already verified, an error is
// returned. If the verification code is not expired, an error is returned. If
// the verification code is found and expired, a new verification code is sent
// to the user email. If any other error occurs, a generic error is returned.
func (a *API) resendUserVerificationCodeHandler(w http.ResponseWriter, r *http.Request) {
verification := &UserVerification{}
if err := json.NewDecoder(r.Body).Decode(verification); err != nil {
ErrMalformedBody.Write(w)
return
}
// check the email or the phone number is not empty
if verification.Email == "" && verification.Phone == "" {
ErrInvalidUserData.With("no email or phone number provided").Write(w)
return
}
var err error
var user *db.User
// get the user information from the database by email or phone
if verification.Email != "" {
user, err = a.db.UserByEmail(verification.Email)
} else {
user, err = a.db.UserByPhone(verification.Phone)
}
// check the error getting the user information
if err != nil {
if err == db.ErrNotFound {
ErrUnauthorized.Write(w)
return
}
ErrGenericInternalServerError.Write(w)
return
}
// check the user is not already verified
if user.Verified {
ErrUserAlreadyVerified.Write(w)
return
}
// get the verification code from the database
code, err := a.db.UserVerificationCode(user, db.CodeTypeAccountVerification)
if err != nil {
if err != db.ErrNotFound {
log.Warnw("could not get verification code", "error", err)
}
ErrUnauthorized.Write(w)
return
}
// if the verification code is not expired, return an error
if code.Expiration.After(time.Now()) {
ErrVerificationCodeValid.Write(w)
return
}
// set a new code and send it
if err := a.sendUserCode(r.Context(), user, db.CodeTypeAccountVerification); err != nil {
log.Warnw("could not send verification code", "error", err)
ErrGenericInternalServerError.Write(w)
return
}
httpWriteOK(w)
}

// userInfoHandler handles the request to get the information of the current
// authenticated user.
func (a *API) userInfoHandler(w http.ResponseWriter, r *http.Request) {
Expand Down
Loading

0 comments on commit ad170ff

Please sign in to comment.