Skip to content

Commit

Permalink
Add change_password verification intent, unify password setting endpo…
Browse files Browse the repository at this point in the history
…ints
  • Loading branch information
DJAndries committed Nov 8, 2024
1 parent cdcaed4 commit 619d830
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 517 deletions.
209 changes: 73 additions & 136 deletions controllers/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ var ErrIncorrectVerificationIntent = errors.New("incorrect verification intent")
type AccountsController struct {
opaqueService *services.OpaqueService
validate *validator.Validate
jwtService *services.JWTService
jwtService *services.JWTService
ds *datastore.Datastore
}

Expand Down Expand Up @@ -149,23 +149,55 @@ func NewAccountsController(opaqueService *services.OpaqueService, jwtService *se
return &AccountsController{
opaqueService: opaqueService,
validate: validator.New(validator.WithRequiredStructEnabled()),
jwtService: jwtService,
jwtService: jwtService,
ds: ds,
}
}

func (ac *AccountsController) Router(permissiveAuthMiddleware func(http.Handler) http.Handler, verificationAuthMiddleware func(http.Handler) http.Handler) chi.Router {
func (ac *AccountsController) Router(verificationMiddleware func(http.Handler) http.Handler) chi.Router {
r := chi.NewRouter()

r.With(verificationAuthMiddleware).Post("/setup/init", ac.SetupPasswordInit)
r.With(verificationAuthMiddleware).Post("/setup/finalize", ac.SetupPasswordFinalize)
r.With(permissiveAuthMiddleware).Post("/change_pwd/init", ac.ChangePasswordInit)
r.With(permissiveAuthMiddleware).Post("/change_pwd/finalize", ac.ChangePasswordFinalize)
r.With(verificationMiddleware).Post("/password/init", ac.SetupPasswordInit)
r.With(verificationMiddleware).Post("/password/finalize", ac.SetupPasswordFinalize)

return r
}

func (ac *AccountsController) setupPasswordInitHelper(email string, w http.ResponseWriter, r *http.Request) {
func checkVerificationStatusAndIntent(w http.ResponseWriter, r *http.Request, verification *datastore.Verification) bool {
if !verification.Verified {
util.RenderErrorResponse(w, r, http.StatusForbidden, ErrEmailNotVerified)
return false
}

if verification.Intent != registrationIntent && verification.Intent != resetIntent && verification.Intent != changePasswordIntent {
util.RenderErrorResponse(w, r, http.StatusForbidden, ErrIncorrectVerificationIntent)
return false
}
return true
}

// @Summary Initialize password setup
// @Description Start the password setup process using OPAQUE protocol.
// @Description If `serializeResponse` is set to true, the `serializedResponse` field will be populated
// @Description in the response, with other fields omitted.
// @Tags Accounts
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer + verification token"
// @Param request body RegistrationRequest true "Registration request"
// @Success 200 {object} RegistrationResponse
// @Failure 400 {object} util.ErrorResponse
// @Failure 401 {object} util.ErrorResponse
// @Failure 403 {object} util.ErrorResponse
// @Failure 500 {object} util.ErrorResponse
// @Router /v2/accounts/password/init [post]
func (ac *AccountsController) SetupPasswordInit(w http.ResponseWriter, r *http.Request) {
verification := r.Context().Value(middleware.ContextVerification).(*datastore.Verification)

if !checkVerificationStatusAndIntent(w, r, verification) {
return
}

var requestData RegistrationRequest
if err := render.DecodeJSON(r.Body, &requestData); err != nil {
util.RenderErrorResponse(w, r, http.StatusBadRequest, err)
Expand All @@ -183,7 +215,7 @@ func (ac *AccountsController) setupPasswordInitHelper(email string, w http.Respo
return
}

opaqueResponse, err := ac.opaqueService.SetupPasswordInit(email, opaqueReq)
opaqueResponse, err := ac.opaqueService.SetupPasswordInit(verification.Email, opaqueReq)
if err != nil {
util.RenderErrorResponse(w, r, http.StatusInternalServerError, err)
return
Expand All @@ -199,25 +231,47 @@ func (ac *AccountsController) setupPasswordInitHelper(email string, w http.Respo
render.JSON(w, r, response)
}

func (ac *AccountsController) setupPasswordFinalizeHelper(email string, w http.ResponseWriter, r *http.Request) *PasswordFinalizeResponse {
// @Summary Finalize password setup
// @Description Complete the password setup process and return auth token.
// @Description Either `publicKey`, `maskingKey` and `envelope` must be provided together,
// @Description or `serializedRecord` must be provided.
// @Tags Accounts
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer + verification token"
// @Param request body RegistrationRecord true "Registration record"
// @Success 200 {object} PasswordFinalizeResponse
// @Failure 400 {object} util.ErrorResponse
// @Failure 401 {object} util.ErrorResponse
// @Failure 403 {object} util.ErrorResponse
// @Failure 404 {object} util.ErrorResponse
// @Failure 500 {object} util.ErrorResponse
// @Router /v2/accounts/password/finalize [post]
func (ac *AccountsController) SetupPasswordFinalize(w http.ResponseWriter, r *http.Request) {
verification := r.Context().Value(middleware.ContextVerification).(*datastore.Verification)

if !checkVerificationStatusAndIntent(w, r, verification) {
return
}

var requestData RegistrationRecord
if err := render.DecodeJSON(r.Body, &requestData); err != nil {
util.RenderErrorResponse(w, r, http.StatusBadRequest, err)
return nil
return
}

if err := ac.validate.Struct(requestData); err != nil {
util.RenderErrorResponse(w, r, http.StatusBadRequest, err)
return nil
return
}

opaqueRecord, err := requestData.ToOpaqueRecord(ac.opaqueService)
if err != nil {
util.RenderErrorResponse(w, r, http.StatusBadRequest, err)
return nil
return
}

account, err := ac.opaqueService.SetupPasswordFinalize(email, opaqueRecord)
account, err := ac.opaqueService.SetupPasswordFinalize(verification.Email, opaqueRecord)
if err != nil {
switch {
case errors.Is(err, datastore.ErrRegistrationStateNotFound):
Expand All @@ -227,89 +281,18 @@ func (ac *AccountsController) setupPasswordFinalizeHelper(email string, w http.R
default:
util.RenderErrorResponse(w, r, http.StatusInternalServerError, err)
}
return nil
return
}

session, err := ac.ds.CreateSession(account.ID, datastore.PasswordAuthSessionVersion, r.UserAgent())
if err != nil {
util.RenderErrorResponse(w, r, http.StatusInternalServerError, err)
return nil
return
}

authToken, err := ac.jwtService.CreateAuthToken(session.ID)
if err != nil {
util.RenderErrorResponse(w, r, http.StatusInternalServerError, err)
return nil
}

return &PasswordFinalizeResponse{
AuthToken: authToken,
}
}

func checkVerificationStatusAndIntent(w http.ResponseWriter, r *http.Request, verification *datastore.Verification) bool {
if !verification.Verified {
util.RenderErrorResponse(w, r, http.StatusForbidden, ErrEmailNotVerified)
return false
}

if verification.Intent != registrationIntent && verification.Intent != resetIntent {
util.RenderErrorResponse(w, r, http.StatusForbidden, ErrIncorrectVerificationIntent)
return false
}
return true
}

// @Summary Initialize password setup
// @Description Start the password setup process using OPAQUE protocol.
// @Description If `serializeResponse` is set to true, the `serializedResponse` field will be populated
// @Description in the response, with other fields omitted.
// @Tags Accounts
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer + verification token"
// @Param request body RegistrationRequest true "Registration request"
// @Success 200 {object} RegistrationResponse
// @Failure 400 {object} util.ErrorResponse
// @Failure 401 {object} util.ErrorResponse
// @Failure 403 {object} util.ErrorResponse
// @Failure 500 {object} util.ErrorResponse
// @Router /v2/accounts/setup/init [post]
func (ac *AccountsController) SetupPasswordInit(w http.ResponseWriter, r *http.Request) {
verification := r.Context().Value(middleware.ContextVerification).(*datastore.Verification)

if !checkVerificationStatusAndIntent(w, r, verification) {
return
}

ac.setupPasswordInitHelper(verification.Email, w, r)
}

// @Summary Finalize password setup
// @Description Complete the password setup process and return auth token.
// @Description Either `publicKey`, `maskingKey` and `envelope` must be provided together,
// @Description or `serializedRecord` must be provided.
// @Tags Accounts
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer + verification token"
// @Param request body RegistrationRecord true "Registration record"
// @Success 200 {object} PasswordFinalizeResponse
// @Failure 400 {object} util.ErrorResponse
// @Failure 401 {object} util.ErrorResponse
// @Failure 403 {object} util.ErrorResponse
// @Failure 404 {object} util.ErrorResponse
// @Failure 500 {object} util.ErrorResponse
// @Router /v2/accounts/setup/finalize [post]
func (ac *AccountsController) SetupPasswordFinalize(w http.ResponseWriter, r *http.Request) {
verification := r.Context().Value(middleware.ContextVerification).(*datastore.Verification)

if !checkVerificationStatusAndIntent(w, r, verification) {
return
}

response := ac.setupPasswordFinalizeHelper(verification.Email, w, r)
if response == nil {
return
}

Expand All @@ -319,53 +302,7 @@ func (ac *AccountsController) SetupPasswordFinalize(w http.ResponseWriter, r *ht
}

render.Status(r, http.StatusOK)
render.JSON(w, r, response)
}

// @Summary Initialize password change
// @Description Start the password change process using OPAQUE protocol.
// @Description This endpoint should also be used to upgrade a Phase 1 account to a Phase 2 account.
// @Description If `serializeResponse` is set to true, the `serializedResponse` field will be populated
// @Description in the response, with other fields omitted.
// @Tags Accounts
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer + auth token"
// @Param request body RegistrationRequest true "Registration request"
// @Success 200 {object} RegistrationResponse
// @Failure 400 {object} util.ErrorResponse
// @Failure 401 {object} util.ErrorResponse
// @Failure 500 {object} util.ErrorResponse
// @Router /v2/accounts/change_pwd/init [post]
func (ac *AccountsController) ChangePasswordInit(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(middleware.ContextSession).(*datastore.Session)

ac.setupPasswordInitHelper(session.Account.Email, w, r)
}

// @Summary Finalize password setup
// @Description Complete the password change process and return auth token
// @Description Either `publicKey`, `maskingKey` and `envelope` must be provided together,
// @Description or `serializedRecord` must be provided.
// @Tags Accounts
// @Accept json
// @Produce json
// @Param Authorization header string true "Bearer + auth token"
// @Param request body RegistrationRecord true "Registration record"
// @Success 200 {object} PasswordFinalizeResponse
// @Failure 400 {object} util.ErrorResponse
// @Failure 401 {object} util.ErrorResponse
// @Failure 404 {object} util.ErrorResponse
// @Failure 500 {object} util.ErrorResponse
// @Router /v2/accounts/change_pwd/finalize [post]
func (ac *AccountsController) ChangePasswordFinalize(w http.ResponseWriter, r *http.Request) {
session := r.Context().Value(middleware.ContextSession).(*datastore.Session)

response := ac.setupPasswordFinalizeHelper(session.Account.Email, w, r)
if response == nil {
return
}

render.Status(r, http.StatusOK)
render.JSON(w, r, response)
render.JSON(w, r, &PasswordFinalizeResponse{
AuthToken: authToken,
})
}
4 changes: 2 additions & 2 deletions controllers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (
type AuthController struct {
opaqueService *services.OpaqueService
validate *validator.Validate
jwtService *services.JWTService
jwtService *services.JWTService
ds *datastore.Datastore
}

Expand Down Expand Up @@ -175,7 +175,7 @@ func NewAuthController(opaqueService *services.OpaqueService, jwtService *servic
return &AuthController{
opaqueService: opaqueService,
validate: validator.New(validator.WithRequiredStructEnabled()),
jwtService: jwtService,
jwtService: jwtService,
ds: ds,
}
}
Expand Down
10 changes: 7 additions & 3 deletions controllers/verification.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const (
verificationIntent = "verification"
registrationIntent = "registration"
resetIntent = "reset"
changePasswordIntent = "change_password"
premiumAuthRedirectURLEnv = "PREMIUM_AUTH_REDIRECT_URL"

accountsServiceName = "accounts"
Expand Down Expand Up @@ -53,7 +54,7 @@ type VerifyInitRequest struct {
// Email address to verify
Email string `json:"email" validate:"required,email,ascii" example:"test@example.com"`
// Purpose of verification (e.g., get auth token, simple verification, registration)
Intent string `json:"intent" validate:"required,oneof=auth_token verification registration reset" example:"registration"`
Intent string `json:"intent" validate:"required,oneof=auth_token verification registration reset change_password" example:"registration"`
// Service requesting the verification
Service string `json:"service" validate:"required,oneof=accounts premium inbox-aliases" example:"accounts"`
// Locale for verification email
Expand Down Expand Up @@ -127,6 +128,7 @@ func (vc *VerificationController) Router(verificationAuthMiddleware func(http.Ha
// @Description - `verification`: After verification, do not create an account, but indicate that the email was verified in the "query result" response. Do not allow registration after verification.
// @Description - `registration`: After verification, indicate that the email was verified in the "query result" response. An account may be created by setting a password.
// @Description - `reset`: After verification, indicate that the email was verified in the "query result" response. A password may be set for the existing account.
// @Description - `change_password`: After verification, indicate that the email was verified in the "query result" response. A password may be set for the existing account.
// @Description
// @Description One of the following service names must be provided with the request: `inbox-aliases`, `accounts`, `premium`.
// @Tags Email verification
Expand Down Expand Up @@ -159,7 +161,7 @@ func (vc *VerificationController) VerifyInit(w http.ResponseWriter, r *http.Requ
if requestData.Service != inboxAliasesServiceName && requestData.Service != premiumServiceName {
intentAllowed = false
}
case registrationIntent, resetIntent:
case registrationIntent, resetIntent, changePasswordIntent:
if !vc.passwordAuthEnabled || requestData.Service != accountsServiceName {
intentAllowed = false
}
Expand Down Expand Up @@ -198,7 +200,9 @@ func (vc *VerificationController) VerifyInit(w http.ResponseWriter, r *http.Requ
}

var verificationToken *string
if requestData.Intent == authTokenIntent || requestData.Intent == verificationIntent || requestData.Intent == registrationIntent || requestData.Intent == resetIntent {

switch requestData.Intent {
case authTokenIntent, verificationIntent, registrationIntent, resetIntent, changePasswordIntent:
token, err := vc.jwtService.CreateVerificationToken(verification.ID, datastore.VerificationExpiration, requestData.Service)
if err != nil {
util.RenderErrorResponse(w, r, http.StatusInternalServerError, err)
Expand Down
Loading

0 comments on commit 619d830

Please sign in to comment.