Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: expirable verification codes for user account registration #16

Merged
merged 8 commits into from
Sep 30, 2024
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"
emmdim marked this conversation as resolved.
Show resolved Hide resolved
// 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")
lucasmenendez marked this conversation as resolved.
Show resolved Hide resolved
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
}
lucasmenendez marked this conversation as resolved.
Show resolved Hide resolved
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
Loading