Repository: supabase/gotrue
Pull request #427 (2022-03-23 16:41:16)

fix: allow enforcing of reauthentication when user updates password
## What kind of change does this PR introduce?
* Supersedes #309 
* Added `reauthentication_token` and `reauthentication_sent_at` to `auth.users` table
* When `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION=true`, if a user attempts to update their password, an error will be returned. They will then have to use `GET /reauthenticate` to get a nonce and make another request to `PUT /user` with the nonce:
```
GET /reauthenticate
Header: "Authorization: Bearer access_token"
{} 

PUT /user

// No nonce provided
{ 
  "password": "new_password"
} 
{
  "code":401,
  "msg":"Password update requires reauthentication."
}

// Valid nonce
{
  "password": "new_password",
  "nonce": "123456"
}

{...}

// Invalid nonce
{
  "email": "foo@example.com"
  "password": "new_password",
  "nonce": "invalid nonce"
}

{
  "code":401,
  "msg":"Nonce has expired or is invalid."
}
```
* Implementation is generic enough if there are other parts of the api that require reauthentication (i.e: updating the user's email etc)
* Rollback all updated fields as long as one update fails since its a PUT request

## Update / Reset Password Flow
1. `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION=true` 
2. Signed-in user attempts to update password
3. `PUT /user` returns error: "Password update requires reauthentication"
5. `GET /reauthenticate` sends a nonce to the user's email (preferred) or phone 
6. User updates password again with the nonce 
8. If nonce is determined to be valid (correct match & before the expiry), password is updated
  
## To-Do
- [x] Reauthentication should be allowed to be sent via SMS if the user has a phone number
- [x] Tests
- [ ] Logout all existing users on password update (to be done in a separate PR)


Patch:
diff --git a/README.md b/README.md
index 0c924249..21897503 100644
--- a/README.md
+++ b/README.md
@@ -494,6 +494,12 @@ for now the only option supported is: `hcaptcha`
 
 Retrieve from hcaptcha account
 
+### Reauthentication
+
+`SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` - `bool`
+
+Enforce reauthentication on password update.
+
 ## Endpoints
 
 GoTrue exposes the following endpoints:
@@ -914,6 +920,25 @@ Returns:
 }
 ```
 
+If `GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION` is enabled, the user will need to reauthenticate first. 
+
+```json
+{
+  "password": "new-password",
+  "nonce": "123456",
+}
+```
+
+### **GET /reauthenticate**
+
+Sends a nonce to the user's email (preferred) or phone. This endpoint requires the user to be logged in / authenticated first. The user needs to have either an email or phone number for the nonce to be sent successfully.
+
+```json
+headers: {
+  "Authorization" : "Bearer eyJhbGciOiJI...M3A90LCkxxtX9oNP9KZO"
+}
+```
+
 ### **POST /logout**
 
 Logout a user (Requires authentication).
diff --git a/api/api.go b/api/api.go
index 6ea19785..ebe1f6b1 100644
--- a/api/api.go
+++ b/api/api.go
@@ -141,6 +141,11 @@ func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfigurati
 
 		r.With(api.requireAuthentication).Post("/logout", api.Logout)
 
+		r.Route("/reauthenticate", func(r *router) {
+			r.Use(api.requireAuthentication)
+			r.Get("/", api.Reauthenticate)
+		})
+
 		r.Route("/user", func(r *router) {
 			r.Use(api.requireAuthentication)
 			r.Get("/", api.UserGet)
diff --git a/api/mail.go b/api/mail.go
index 5d9ea140..c8ab003c 100644
--- a/api/mail.go
+++ b/api/mail.go
@@ -212,6 +212,22 @@ func (a *API) sendPasswordRecovery(tx *storage.Connection, u *models.User, maile
 	return errors.Wrap(tx.UpdateOnly(u, "recovery_token", "recovery_sent_at"), "Database error updating user for recovery")
 }
 
+func (a *API) sendReauthenticationOtp(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration) error {
+	if u.ReauthenticationSentAt != nil && !u.ReauthenticationSentAt.Add(maxFrequency).Before(time.Now()) {
+		return MaxFrequencyLimitError
+	}
+
+	oldToken := u.ReauthenticationToken
+	u.ReauthenticationToken = crypto.SecureToken()
+	now := time.Now()
+	if err := mailer.ReauthenticateMail(u); err != nil {
+		u.ReauthenticationToken = oldToken
+		return errors.Wrap(err, "Error sending reauthentication email")
+	}
+	u.ReauthenticationSentAt = &now
+	return errors.Wrap(tx.UpdateOnly(u, "reauthentication_token", "reauthentication_sent_at"), "Database error updating user for reauthentication")
+}
+
 func (a *API) sendMagicLink(tx *storage.Connection, u *models.User, mailer mailer.Mailer, maxFrequency time.Duration, referrerURL string) error {
 	// since Magic Link is just a recovery with a different template and behaviour
 	// around new users we will reuse the recovery db timer to prevent potential abuse
diff --git a/api/phone.go b/api/phone.go
index 2dcb1338..aa6833f1 100644
--- a/api/phone.go
+++ b/api/phone.go
@@ -18,8 +18,8 @@ const e164Format = `^[1-9]\d{1,14}$`
 const defaultSmsMessage = "Your code is %v"
 
 const (
-	phoneChangeOtp       = "phone_change"
-	phoneConfirmationOtp = "confirmation"
+	phoneConfirmationOtp     = "confirmation"
+	phoneReauthenticationOtp = "reauthentication"
 )
 
 func (a *API) validatePhone(phone string) (string, error) {
@@ -50,15 +50,20 @@ func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection,
 	var sentAt *time.Time
 	var tokenDbField, sentAtDbField string
 
-	if otpType == phoneConfirmationOtp {
-		token = &user.ConfirmationToken
-		sentAt = user.ConfirmationSentAt
-		tokenDbField, sentAtDbField = "confirmation_token", "confirmation_sent_at"
-	} else if otpType == phoneChangeOtp {
+	switch otpType {
+	case phoneChangeVerification:
 		token = &user.PhoneChangeToken
 		sentAt = user.PhoneChangeSentAt
 		tokenDbField, sentAtDbField = "phone_change_token", "phone_change_sent_at"
-	} else {
+	case phoneConfirmationOtp:
+		token = &user.ConfirmationToken
+		sentAt = user.ConfirmationSentAt
+		tokenDbField, sentAtDbField = "confirmation_token", "confirmation_sent_at"
+	case phoneReauthenticationOtp:
+		token = &user.ReauthenticationToken
+		sentAt = user.ReauthenticationSentAt
+		tokenDbField, sentAtDbField = "reauthentication_token", "reauthentication_sent_at"
+	default:
 		return internalServerError("invalid otp type")
 	}
 
@@ -86,10 +91,14 @@ func (a *API) sendPhoneConfirmation(ctx context.Context, tx *storage.Connection,
 	}
 
 	now := time.Now()
-	if otpType == phoneConfirmationOtp {
+
+	switch otpType {
+	case phoneConfirmationOtp:
 		user.ConfirmationSentAt = &now
-	} else if otpType == phoneChangeOtp {
+	case phoneChangeVerification:
 		user.PhoneChangeSentAt = &now
+	case phoneReauthenticationOtp:
+		user.ReauthenticationSentAt = &now
 	}
 
 	return errors.Wrap(tx.UpdateOnly(user, tokenDbField, sentAtDbField), "Database error updating user for confirmation")
diff --git a/api/reauthenticate.go b/api/reauthenticate.go
new file mode 100644
index 00000000..307b8b15
--- /dev/null
+++ b/api/reauthenticate.go
@@ -0,0 +1,98 @@
+package api
+
+import (
+	"errors"
+	"net/http"
+	"time"
+
+	"github.com/gofrs/uuid"
+	"github.com/netlify/gotrue/api/sms_provider"
+	"github.com/netlify/gotrue/conf"
+	"github.com/netlify/gotrue/models"
+	"github.com/netlify/gotrue/storage"
+)
+
+// Reauthenticate sends a reauthentication otp to either the user's email or phone
+func (a *API) Reauthenticate(w http.ResponseWriter, r *http.Request) error {
+	ctx := r.Context()
+	config := a.getConfig(ctx)
+	instanceID := getInstanceID(ctx)
+
+	claims := getClaims(ctx)
+	userID, err := uuid.FromString(claims.Subject)
+	if err != nil {
+		return badRequestError("Could not read User ID claim")
+	}
+	user, err := models.FindUserByID(a.db, userID)
+	if err != nil {
+		if models.IsNotFoundError(err) {
+			return notFoundError(err.Error())
+		}
+		return internalServerError("Database error finding user").WithInternalError(err)
+	}
+
+	email, phone := user.GetEmail(), user.GetPhone()
+
+	if email == "" && phone == "" {
+		return unprocessableEntityError("Reauthentication requires the user to have an email or a phone number")
+	}
+
+	if email != "" {
+		if !user.IsConfirmed() {
+			return badRequestError("Please verify your email first.")
+		}
+	} else if phone != "" {
+		if !user.IsPhoneConfirmed() {
+			return badRequestError("Please verify your phone first.")
+		}
+	}
+
+	err = a.db.Transaction(func(tx *storage.Connection) error {
+		if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserReauthenticateAction, nil); terr != nil {
+			return terr
+		}
+		if email != "" {
+			mailer := a.Mailer(ctx)
+			return a.sendReauthenticationOtp(tx, user, mailer, config.SMTP.MaxFrequency)
+		} else if phone != "" {
+			smsProvider, err := sms_provider.GetSmsProvider(*config)
+			if err != nil {
+				return err
+			}
+			return a.sendPhoneConfirmation(ctx, tx, user, phone, recoveryVerification, smsProvider)
+		}
+		return nil
+	})
+	if err != nil {
+		if errors.Is(err, MaxFrequencyLimitError) {
+			return tooManyRequestsError("For security purposes, you can only request this once every 60 seconds")
+		}
+		return internalServerError("Reauthentication failed.").WithInternalError(err)
+	}
+
+	return sendJSON(w, http.StatusOK, make(map[string]string))
+}
+
+// verifyReauthentication checks if the nonce provided is valid
+func (a *API) verifyReauthentication(nonce string, tx *storage.Connection, config *conf.Configuration, user *models.User) error {
+	if user.ReauthenticationToken == "" || user.ReauthenticationSentAt == nil {
+		return unauthorizedError("Requires reauthentication")
+	}
+	var isValid bool
+	if user.GetEmail() != "" {
+		mailerOtpExpiresAt := time.Second * time.Duration(config.Mailer.OtpExp)
+		isValid = isOtpValid(nonce, user.ReauthenticationToken, user.ReauthenticationSentAt.Add(mailerOtpExpiresAt))
+	} else if user.GetPhone() != "" {
+		smsOtpExpiresAt := time.Second * time.Duration(config.Sms.OtpExp)
+		isValid = isOtpValid(nonce, user.ReauthenticationToken, user.ReauthenticationSentAt.Add(smsOtpExpiresAt))
+	} else {
+		return unprocessableEntityError("Reauthentication requires an email or a phone number")
+	}
+	if !isValid {
+		return badRequestError("Nonce has expired or is invalid")
+	}
+	if err := user.ConfirmReauthentication(tx); err != nil {
+		return internalServerError("Error during reauthentication").WithInternalError(err)
+	}
+	return nil
+}
diff --git a/api/recover.go b/api/recover.go
index 2853e754..94de22ff 100644
--- a/api/recover.go
+++ b/api/recover.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"net/http"
 
+	"github.com/netlify/gotrue/api/sms_provider"
 	"github.com/netlify/gotrue/models"
 	"github.com/netlify/gotrue/storage"
 )
@@ -12,6 +13,7 @@ import (
 // RecoverParams holds the parameters for a password recovery request
 type RecoverParams struct {
 	Email string `json:"email"`
+	Phone string `json:"phone"`
 }
 
 // Recover sends a recovery email
@@ -26,13 +28,26 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 		return badRequestError("Could not read verification params: %v", err)
 	}
 
-	if params.Email == "" {
-		return unprocessableEntityError("Password recovery requires an email")
+	if params.Email == "" && params.Phone == "" {
+		return unprocessableEntityError("Password recovery requires an email or a phone number")
 	}
 
+	var user *models.User
 	aud := a.requestAud(ctx, r)
-	user, err := models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud)
 	recoverErrorMessage := "If a user exists, you will receive an email with instructions on how to reset your password."
+	if params.Email != "" {
+		if err := a.validateEmail(ctx, params.Email); err != nil {
+			return err
+		}
+		user, err = models.FindUserByEmailAndAudience(a.db, instanceID, params.Email, aud)
+	} else if params.Phone != "" {
+		params.Phone, err = a.validatePhone(params.Phone)
+		if err != nil {
+			return err
+		}
+		user, err = models.FindUserByPhoneAndAudience(a.db, instanceID, params.Phone, aud)
+	}
+
 	if err != nil {
 		if models.IsNotFoundError(err) {
 			return notFoundError(err.Error())
@@ -44,10 +59,18 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
 		if terr := models.NewAuditLogEntry(tx, instanceID, user, models.UserRecoveryRequestedAction, nil); terr != nil {
 			return terr
 		}
-
-		mailer := a.Mailer(ctx)
-		referrer := a.getReferrer(r)
-		return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer)
+		if params.Email != "" {
+			mailer := a.Mailer(ctx)
+			referrer := a.getReferrer(r)
+			return a.sendPasswordRecovery(tx, user, mailer, config.SMTP.MaxFrequency, referrer)
+		} else if params.Phone != "" {
+			smsProvider, err := sms_provider.GetSmsProvider(*config)
+			if err != nil {
+				return err
+			}
+			return a.sendPhoneConfirmation(ctx, tx, user, params.Phone, recoveryVerification, smsProvider)
+		}
+		return nil
 	})
 	if err != nil {
 		if errors.Is(err, MaxFrequencyLimitError) {
diff --git a/api/user.go b/api/user.go
index ba99dfeb..c7ab19e4 100644
--- a/api/user.go
+++ b/api/user.go
@@ -14,6 +14,7 @@ import (
 type UserUpdateParams struct {
 	Email    string                 `json:"email"`
 	Password *string                `json:"password"`
+	Nonce    string                 `json:"nonce"`
 	Data     map[string]interface{} `json:"data"`
 	AppData  map[string]interface{} `json:"app_metadata,omitempty"`
 	Phone    string                 `json:"phone"`
@@ -85,8 +86,19 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 				return invalidPasswordLengthError(config)
 			}
 
-			if terr = user.UpdatePassword(tx, *params.Password); terr != nil {
-				return internalServerError("Error during password storage").WithInternalError(terr)
+			if !config.Security.UpdatePasswordRequireReauthentication {
+				if terr = user.UpdatePassword(tx, *params.Password); terr != nil {
+					return internalServerError("Error during password storage").WithInternalError(terr)
+				}
+			} else if params.Nonce == "" {
+				return unauthorizedError("Password update requires reauthentication.")
+			} else {
+				if terr = a.verifyReauthentication(params.Nonce, tx, config, user); terr != nil {
+					return terr
+				}
+				if terr = user.UpdatePassword(tx, *params.Password); terr != nil {
+					return internalServerError("Error during password storage").WithInternalError(terr)
+				}
 			}
 		}
 
@@ -143,7 +155,7 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
 				if terr != nil {
 					return terr
 				}
-				if terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeOtp, smsProvider); terr != nil {
+				if terr := a.sendPhoneConfirmation(ctx, tx, user, params.Phone, phoneChangeVerification, smsProvider); terr != nil {
 					return internalServerError("Error sending phone change otp").WithInternalError(terr)
 				}
 			}
diff --git a/api/verify.go b/api/verify.go
index 655a2c55..844ef85b 100644
--- a/api/verify.go
+++ b/api/verify.go
@@ -338,7 +338,7 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 
 	var user *models.User
 	var err error
-	if isUrlVerification(params) {
+	if isUrlLinkVerification(params) {
 		switch params.Type {
 		case signupVerification, inviteVerification:
 			user, err = models.FindUserByConfirmationToken(conn, params.Token)
@@ -347,7 +347,7 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 		case emailChangeVerification:
 			user, err = models.FindUserByEmailChangeToken(conn, params.Token)
 		}
-	} else if params.Type == smsVerification || params.Type == phoneChangeVerification {
+	} else if isPhoneOtpVerification(params) {
 		if params.Phone == "" {
 			return nil, unprocessableEntityError("Sms Verification requires a phone number")
 		}
@@ -356,11 +356,13 @@ func (a *API) verifyUserAndToken(ctx context.Context, conn *storage.Connection,
 			return nil, err
 		}
 		user, err = models.FindUserByPhoneAndAudience(conn, instanceID, params.Phone, aud)
-	} else if params.Email != "" {
+	} else if isEmailOtpVerification(params) {
 		if err := a.validateEmail(ctx, params.Email); err != nil {
 			return nil, unprocessableEntityError("Invalid email format").WithInternalError(err)
 		}
 		user, err = models.FindUserByEmailAndAudience(conn, instanceID, params.Email, aud)
+	} else {
+		return nil, badRequestError("Only an email address or phone number should be provided on verify, not both.")
 	}
 
 	if err != nil {
@@ -407,7 +409,17 @@ func isOtpValid(actual, expected string, expiresAt time.Time) bool {
 	return time.Now().Before(expiresAt) && (actual == expected)
 }
 
-func isUrlVerification(params *VerifyParams) bool {
-	isPhoneVerification := params.Type == smsVerification || params.Type == phoneChangeVerification
-	return !isPhoneVerification && params.Email == ""
+// isUrlLinkVerification checks if the verification came from clicking an email link which wouldn't contain the email field in the params
+func isUrlLinkVerification(params *VerifyParams) bool {
+	return params.Phone == "" && params.Email == ""
+}
+
+// isPhoneOtpVerification checks if the verification came from a phone otp
+func isPhoneOtpVerification(params *VerifyParams) bool {
+	return params.Phone != "" && params.Email == ""
+}
+
+// isEmailOtpVerification checks if the verification came from an email otp
+func isEmailOtpVerification(params *VerifyParams) bool {
+	return params.Phone == "" && params.Email != ""
 }
diff --git a/conf/configuration.go b/conf/configuration.go
index 7b713e64..23aacf32 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -78,11 +78,12 @@ type GlobalConfiguration struct {
 
 // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs.
 type EmailContentConfiguration struct {
-	Invite       string `json:"invite"`
-	Confirmation string `json:"confirmation"`
-	Recovery     string `json:"recovery"`
-	EmailChange  string `json:"email_change" split_words:"true"`
-	MagicLink    string `json:"magic_link" split_words:"true"`
+	Invite           string `json:"invite"`
+	Confirmation     string `json:"confirmation"`
+	Recovery         string `json:"recovery"`
+	EmailChange      string `json:"email_change" split_words:"true"`
+	MagicLink        string `json:"magic_link" split_words:"true"`
+	Reauthentication string `json:"reauthentication"`
 }
 
 type ProviderConfiguration struct {
@@ -175,8 +176,9 @@ type CaptchaConfiguration struct {
 }
 
 type SecurityConfiguration struct {
-	Captcha                     CaptchaConfiguration `json:"captcha"`
-	RefreshTokenRotationEnabled bool                 `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
+	Captcha                               CaptchaConfiguration `json:"captcha"`
+	RefreshTokenRotationEnabled           bool                 `json:"refresh_token_rotation_enabled" split_words:"true" default:"true"`
+	UpdatePasswordRequireReauthentication bool                 `json:"update_password_require_reauthentication" split_words:"true"`
 }
 
 // Configuration holds all the per-instance configuration.
diff --git a/mailer/mailer.go b/mailer/mailer.go
index 28fbea68..3441eb5a 100644
--- a/mailer/mailer.go
+++ b/mailer/mailer.go
@@ -19,6 +19,7 @@ type Mailer interface {
 	RecoveryMail(user *models.User, referrerURL string) error
 	MagicLinkMail(user *models.User, referrerURL string) error
 	EmailChangeMail(user *models.User, referrerURL string) error
+	ReauthenticateMail(user *models.User) error
 	ValidateEmail(email string) error
 	GetEmailActionLink(user *models.User, actionType, referrerURL string) (string, error)
 }
diff --git a/mailer/template.go b/mailer/template.go
index bda1ab5b..64673edb 100644
--- a/mailer/template.go
+++ b/mailer/template.go
@@ -52,6 +52,10 @@ const defaultEmailChangeMail = `<h2>Confirm email address change</h2>
 <p><a href="{{ .ConfirmationURL }}">Change email address</a></p>
 <p>Alternatively, enter the code: {{ .Token }}</p>`
 
+const defaultReauthenticateMail = `<h2>Confirm reauthentication</h2>
+
+<p>Enter the code: {{ .Token }}</p>`
+
 // ValidateEmail returns nil if the email is valid,
 // otherwise an error indicating the reason it is invalid
 func (m TemplateMailer) ValidateEmail(email string) error {
@@ -118,6 +122,24 @@ func (m *TemplateMailer) ConfirmationMail(user *models.User, referrerURL string)
 	)
 }
 
+// ReauthenticateMail sends a reauthentication mail to an authenticated user
+func (m *TemplateMailer) ReauthenticateMail(user *models.User) error {
+	data := map[string]interface{}{
+		"SiteURL": m.Config.SiteURL,
+		"Email":   user.Email,
+		"Token":   user.ReauthenticationToken,
+		"Data":    user.UserMetaData,
+	}
+
+	return m.Mailer.Mail(
+		user.GetEmail(),
+		string(withDefault(m.Config.Mailer.Subjects.Reauthentication, "Confirm reauthentication")),
+		m.Config.Mailer.Templates.Reauthentication,
+		defaultReauthenticateMail,
+		data,
+	)
+}
+
 // EmailChangeMail sends an email change confirmation mail to a user
 func (m *TemplateMailer) EmailChangeMail(user *models.User, referrerURL string) error {
 	type Email struct {
diff --git a/migrations/20220323170000_add_user_reauthentication.up.sql b/migrations/20220323170000_add_user_reauthentication.up.sql
new file mode 100644
index 00000000..3b6e60b7
--- /dev/null
+++ b/migrations/20220323170000_add_user_reauthentication.up.sql
@@ -0,0 +1,5 @@
+-- adds reauthentication_token and reauthentication_sent_at 
+
+ALTER TABLE auth.users
+ADD COLUMN IF NOT EXISTS reauthentication_token varchar(255) null default '',
+ADD COLUMN IF NOT EXISTS reauthentication_sent_at timestamptz null default null;
diff --git a/models/audit_log_entry.go b/models/audit_log_entry.go
index 3aa39b74..72bf6e97 100644
--- a/models/audit_log_entry.go
+++ b/models/audit_log_entry.go
@@ -22,6 +22,7 @@ const (
 	UserDeletedAction               AuditAction = "user_deleted"
 	UserModifiedAction              AuditAction = "user_modified"
 	UserRecoveryRequestedAction     AuditAction = "user_recovery_requested"
+	UserReauthenticateAction        AuditAction = "user_reauthenticate_requested"
 	UserConfirmationRequestedAction AuditAction = "user_confirmation_requested"
 	UserRepeatedSignUpAction        AuditAction = "user_repeated_signup"
 	TokenRevokedAction              AuditAction = "token_revoked"
diff --git a/models/user.go b/models/user.go
index d2f7083e..09de86ec 100644
--- a/models/user.go
+++ b/models/user.go
@@ -50,6 +50,9 @@ type User struct {
 	PhoneChange       string     `json:"new_phone,omitempty" db:"phone_change"`
 	PhoneChangeSentAt *time.Time `json:"phone_change_sent_at,omitempty" db:"phone_change_sent_at"`
 
+	ReauthenticationToken  string     `json:"-" db:"reauthentication_token"`
+	ReauthenticationSentAt *time.Time `json:"reauthentication_sent_at,omitempty" db:"reauthentication_sent_at"`
+
 	LastSignInAt *time.Time `json:"last_sign_in_at,omitempty" db:"last_sign_in_at"`
 
 	AppMetaData  JSONMap `json:"app_metadata" db:"raw_app_meta_data"`
@@ -145,6 +148,9 @@ func (u *User) BeforeSave(tx *pop.Connection) error {
 	if u.PhoneChangeSentAt != nil && u.PhoneChangeSentAt.IsZero() {
 		u.PhoneChangeSentAt = nil
 	}
+	if u.ReauthenticationSentAt != nil && u.ReauthenticationSentAt.IsZero() {
+		u.ReauthenticationSentAt = nil
+	}
 	if u.LastSignInAt != nil && u.LastSignInAt.IsZero() {
 		u.LastSignInAt = nil
 	}
@@ -275,6 +281,12 @@ func (u *User) Authenticate(password string) bool {
 	return err == nil
 }
 
+// ConfirmReauthentication resets the reauthentication token
+func (u *User) ConfirmReauthentication(tx *storage.Connection) error {
+	u.ReauthenticationToken = ""
+	return tx.UpdateOnly(u, "reauthentication_token")
+}
+
 // Confirm resets the confimation token and sets the confirm timestamp
 func (u *User) Confirm(tx *storage.Connection) error {
 	u.ConfirmationToken = ""


Test patch:
diff --git a/api/phone_test.go b/api/phone_test.go
index 9bc4f9c5..9eda8027 100644
--- a/api/phone_test.go
+++ b/api/phone_test.go
@@ -80,7 +80,12 @@ func (ts *PhoneTestSuite) TestSendPhoneConfirmation() {
 		},
 		{
 			"send phone_change otp",
-			phoneChangeOtp,
+			phoneChangeVerification,
+			nil,
+		},
+		{
+			"send recovery otp",
+			phoneReauthenticationOtp,
 			nil,
 		},
 		{
@@ -101,9 +106,12 @@ func (ts *PhoneTestSuite) TestSendPhoneConfirmation() {
 			case phoneConfirmationOtp:
 				require.NotEmpty(ts.T(), u.ConfirmationToken)
 				require.NotEmpty(ts.T(), u.ConfirmationSentAt)
-			case phoneChangeOtp:
+			case phoneChangeVerification:
 				require.NotEmpty(ts.T(), u.PhoneChangeToken)
 				require.NotEmpty(ts.T(), u.PhoneChangeSentAt)
+			case phoneReauthenticationOtp:
+				require.NotEmpty(ts.T(), u.ReauthenticationToken)
+				require.NotEmpty(ts.T(), u.ReauthenticationSentAt)
 			default:
 			}
 		})
diff --git a/api/user_test.go b/api/user_test.go
index b14d5115..585799c7 100644
--- a/api/user_test.go
+++ b/api/user_test.go
@@ -186,34 +186,53 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
 	require.NoError(ts.T(), err)
 
-	var cases = []struct {
-		desc            string
-		update          map[string]interface{}
-		expectedCode    int
+	type expected struct {
+		code            int
 		isAuthenticated bool
+	}
+
+	var cases = []struct {
+		desc                    string
+		newPassword             string
+		nonce                   string
+		requireReauthentication bool
+		expected                expected
 	}{
 		{
 			"Valid password length",
-			map[string]interface{}{
-				"password": "newpass",
-			},
-			http.StatusOK,
-			true,
+			"newpassword",
+			"",
+			false,
+			expected{code: http.StatusOK, isAuthenticated: true},
 		},
 		{
 			"Invalid password length",
-			map[string]interface{}{
-				"password": "",
-			},
-			http.StatusUnprocessableEntity,
+			"",
+			"",
 			false,
+			expected{code: http.StatusUnprocessableEntity, isAuthenticated: false},
+		},
+		{
+			"No reauthentication provided",
+			"newpassword123",
+			"",
+			true,
+			expected{code: http.StatusUnauthorized, isAuthenticated: false},
+		},
+		{
+			"Invalid nonce",
+			"newpassword123",
+			"123456",
+			true,
+			expected{code: http.StatusUnauthorized, isAuthenticated: false},
 		},
 	}
 
 	for _, c := range cases {
 		ts.Run(c.desc, func() {
+			ts.Config.Security.UpdatePasswordRequireReauthentication = c.requireReauthentication
 			var buffer bytes.Buffer
-			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.update))
+			require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]string{"password": c.newPassword, "nonce": c.nonce}))
 
 			req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
 			req.Header.Set("Content-Type", "application/json")
@@ -225,14 +244,64 @@ func (ts *UserTestSuite) TestUserUpdatePassword() {
 			// Setup response recorder
 			w := httptest.NewRecorder()
 			ts.API.handler.ServeHTTP(w, req)
-			require.Equal(ts.T(), w.Code, c.expectedCode)
+			require.Equal(ts.T(), w.Code, c.expected.code)
 
 			// Request body
 			u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
 			require.NoError(ts.T(), err)
 
-			passwordUpdate, _ := c.update["password"].(string)
-			require.Equal(ts.T(), c.isAuthenticated, u.Authenticate(passwordUpdate))
+			require.Equal(ts.T(), c.expected.isAuthenticated, u.Authenticate(c.newPassword))
 		})
 	}
 }
+
+func (ts *UserTestSuite) TestUserUpdatePasswordReauthentication() {
+	ts.Config.Security.UpdatePasswordRequireReauthentication = true
+
+	// create a confirmed user
+	u, err := models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	now := time.Now()
+	u.EmailConfirmedAt = &now
+	require.NoError(ts.T(), ts.API.db.Update(u), "Error updating new test user")
+
+	token, err := generateAccessToken(u, time.Second*time.Duration(ts.Config.JWT.Exp), ts.Config.JWT.Secret)
+	require.NoError(ts.T(), err)
+
+	// request for reauthentication nonce
+	req := httptest.NewRequest(http.MethodGet, "http://localhost/reauthenticate", nil)
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+	w := httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), w.Code, http.StatusOK)
+
+	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+	require.NotEmpty(ts.T(), u.ReauthenticationToken)
+	require.NotEmpty(ts.T(), u.ReauthenticationSentAt)
+
+	// update password with reauthentication token
+	var buffer bytes.Buffer
+	require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
+		"password": "newpass",
+		"nonce":    u.ReauthenticationToken,
+	}))
+
+	req = httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
+	req.Header.Set("Content-Type", "application/json")
+
+	req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
+
+	w = httptest.NewRecorder()
+	ts.API.handler.ServeHTTP(w, req)
+	require.Equal(ts.T(), w.Code, http.StatusOK)
+
+	// Request body
+	u, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "test@example.com", ts.Config.JWT.Aud)
+	require.NoError(ts.T(), err)
+
+	require.True(ts.T(), u.Authenticate("newpass"))
+	require.Empty(ts.T(), u.ReauthenticationToken)
+	require.NotEmpty(ts.T(), u.ReauthenticationSentAt)
+}