Let's Go Further پیوست‌ها › مدیریت بازنشانی رمز عبور
قبلی · فهرست مطالب · بعدی
فصل ۲۱.۱.

مدیریت بازنشانی رمز عبور

اگر API‌ای که می‌سازید برای مخاطبان عمومی است، احتمالاً می‌خواهید قابلیت بازنشانی رمز عبور را برای کاربرانی که رمز عبور خود را فراموش کرده‌اند اضافه کنید.

برای پشتیبانی از این قابلیت در API خود، می‌توانید دو endpoint زیر را اضافه کنید:

متد الگوی URL Handler عملیات
POST /v1/tokens/password-reset createPasswordResetTokenHandler تولید یک token جدید برای بازنشانی رمز عبور
PUT /v1/users/password updateUserPasswordHandler به‌روزرسانی رمز عبور برای یک کاربر مشخص

و یک فرآیند کاری مانند زیر پیاده‌سازی کنید:

  1. یک client درخواستی به endpoint POST /v1/tokens/password-reset ارسال می‌کند که حاوی آدرس ایمیل کاربر مورد نظر برای بازنشانی رمز عبور است.
  2. اگر کاربری با آن آدرس ایمیل در جدول users وجود داشته باشد، و کاربر قبلاً آدرس ایمیل خود را با فعال‌سازی تأیید کرده باشد، یک token تصادفی با امنیت رمزنگاری و entropy بالا تولید می‌شود.
  3. یک hash از این token در جدول tokens ذخیره می‌شود، همراه با user ID و یک زمان انقضای کوتاه (۳۰ تا ۶۰ دقیقه) برای token.
  4. token اصلی (بدون hash) به کاربر از طریق ایمیل ارسال می‌شود.
  5. اگر مالک آدرس ایمیل درخواست بازنشانی رمز عبور نداده باشد، می‌تواند ایمیل را نادیده بگیرد.
  6. در غیر این صورت، می‌تواند token را همراه با رمز عبور جدید خود به endpoint PUT /v1/users/password ارسال کند. اگر hash token در جدول tokens وجود داشته باشد و منقضی نشده باشد، یک bcrypt hash از رمز عبور جدید تولید شده و رکورد کاربر به‌روزرسانی می‌شود.
  7. تمام token‌های بازنشانی رمز عبور موجود برای کاربر حذف می‌شوند.

در کدپایگاه ما، می‌توانید این فرآیند کاری را با ایجاد یک token scope جدید به نام password-reset پیاده‌سازی کنید:

فایل: internal/data/tokens.go
package data

...

const (
    ScopeActivation     = "activation"
    ScopeAuthentication = "authentication"
    ScopePasswordReset  = "password-reset"
)

...

و سپس دو handler زیر را اضافه کنید:

فایل: cmd/api/tokens.go
package main

...

// Generate a password reset token and send it to the user's email address.
func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r *http.Request) {
    // Parse and validate the user's email address.
    var input struct {
        Email string `json:"email"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()

    if data.ValidateEmail(v, input.Email); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Try to retrieve the corresponding user record for the email address. If it can't
    // be found, return an error message to the client.
    user, err := app.models.Users.GetByEmail(input.Email)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrRecordNotFound):
            v.AddError("email", "no matching email address found")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // Return an error message if the user is not activated.
    if !user.Activated {
        v.AddError("email", "user account must be activated")
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Otherwise, create a new password reset token with a 45-minute expiry time.
    token, err := app.models.Tokens.New(user.ID, 45*time.Minute, data.ScopePasswordReset)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Email the user with their password reset token.
    app.background(func() {
        data := map[string]any{
            "passwordResetToken": token.Plaintext,
        }

        // Since email addresses MAY be case sensitive, notice that we are sending this 
        // email using the address stored in our database for the user --- not to the 
        // input.Email address provided by the client in this request.
        err := app.mailer.Send(user.Email, "token_password_reset.tmpl", data)
        if err != nil {
            app.logger.Error(err.Error())
        }
    })

    // Send a 202 Accepted response and confirmation message to the client.
    env := envelope{"message": "an email will be sent to you containing password reset instructions"}

    err = app.writeJSON(w, http.StatusAccepted, env, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}
فایل: cmd/api/users.go
package main

...

// Verify the password reset token and set a new password for the user.
func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http.Request) {
    // Parse and validate the user's new password and password reset token.
    var input struct {
        Password       string `json:"password"`
        TokenPlaintext string `json:"token"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()

    data.ValidatePasswordPlaintext(v, input.Password)
    data.ValidateTokenPlaintext(v, input.TokenPlaintext)

    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Retrieve the details of the user associated with the password reset token,
    // returning an error message if no matching record was found.
    user, err := app.models.Users.GetForToken(data.ScopePasswordReset, input.TokenPlaintext)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrRecordNotFound):
            v.AddError("token", "invalid or expired password reset token")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // Set the new password for the user.
    err = user.Password.Set(input.Password)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Save the updated user record in our database, checking for any edit conflicts as
    // normal.
    err = app.models.Users.Update(user)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrEditConflict):
            app.editConflictResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // If everything was successful, then delete all password reset tokens for the user.
    err = app.models.Tokens.DeleteAllForUser(data.ScopePasswordReset, user.ID)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Send the user a confirmation message.
    env := envelope{"message": "your password was successfully reset"}

    err = app.writeJSON(w, http.StatusOK, env, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

همچنین باید فایل internal/mailer/templates/token_password_reset.tmpl را ایجاد کنید که حاوی قالب‌های ایمیل برای بازنشانی رمز عبور باشد، مشابه این:

فایل: internal/mailer/templates/token_password_reset.tmpl
{{define "subject"}}Reset your Greenlight password{{end}}

{{define "plainBody"}}
Hi,

Please send a `PUT /v1/users/password` request with the following JSON body to set a new password:

{"password": "your new password", "token": "{{.passwordResetToken}}"}

Please note that this is a one-time use token and it will expire in 45 minutes. If you need 
another token please make a `POST /v1/tokens/password-reset` request.

Thanks,

The Greenlight Team
{{end}}

{{define "htmlBody"}}
<!doctype html>
<html>
  <head>
    <meta name="viewport" content="width=device-width" />
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  </head>
  <body>
    <p>Hi,</p>
    <p>Please send a <code>PUT /v1/users/password</code> request with the following JSON body to set a new password:</p>
    <pre><code>
    {"password": "your new password", "token": "{{.passwordResetToken}}"}
    </code></pre>  
    <p>Please note that this is a one-time use token and it will expire in 45 minutes.
    If you need another token please make a <code>POST /v1/tokens/password-reset</code> request.</p>
    <p>Thanks,</p>
    <p>The Greenlight Team</p>
  </body>
</html>
{{end}}

و route‌های لازم را به فایل cmd/api/routes.go اضافه کنید:

فایل: cmd/api/routes.go
package main

...

func (app *application) routes() http.Handler {
    router := httprouter.New()

    router.NotFound = http.HandlerFunc(app.notFoundResponse)
    router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)

    router.HandlerFunc(http.MethodGet, "/v1/healthcheck", app.healthcheckHandler)

    router.HandlerFunc(http.MethodGet, "/v1/movies", app.requirePermission("movies:read", app.listMoviesHandler))
    router.HandlerFunc(http.MethodPost, "/v1/movies", app.requirePermission("movies:write", app.createMovieHandler))
    router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.requirePermission("movies:read", app.showMovieHandler))
    router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.requirePermission("movies:write", app.updateMovieHandler))
    router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.requirePermission("movies:write", app.deleteMovieHandler))

    router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
    router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
    // Add the PUT /v1/users/password endpoint.
    router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler)

    router.HandlerFunc(http.MethodPost, "/v1/tokens/authentication", app.createAuthenticationTokenHandler)
    // Add the POST /v1/tokens/password-reset endpoint.
    router.HandlerFunc(http.MethodPost, "/v1/tokens/password-reset", app.createPasswordResetTokenHandler)

    router.Handler(http.MethodGet, "/debug/vars", expvar.Handler())

    return app.metrics(app.recoverPanic(app.enableCORS(app.rateLimit(app.authenticate(router)))))
}

پس از آنکه همه چیز در جای خود قرار گرفت، باید بتوانید یک token بازنشانی رمز عبور جدید با ارسال درخواست زیر دریافت کنید:

$ curl -X POST -d '{"email": "alice@example.com"}' localhost:4000/v1/tokens/password-reset
{
    "message": "an email will be sent to you containing password reset instructions"
}

و سپس می‌توانید تغییر رمز عبور واقعی را با ارسال درخواستی حاوی token دریافت شده در ایمیل آغاز کنید. به عنوان مثال:

$ BODY='{"password": "your new password", "token": "Y7QCRZ7FWOWYLXLAOC2VYOLIPY"}'
$ curl -X PUT -d "$BODY" localhost:4000/v1/users/password
{
    "message": "your password was successfully reset"
}

اطلاعات تکمیلی

فرآیند کاری وب‌اپلیکیشن

درست مانند فرآیند فعال‌سازی که قبلاً بررسی کردیم، اگر API شما به عنوان backend یک وب‌سایت عمل می‌کند (نه یک سرویس کاملاً مستقل)، می‌توانید این فرآیند بازنشانی رمز عبور را طوری تنظیم کنید که برای کاربران بصری‌تر باشد.

به عنوان مثال، می‌توانید از کاربر بخواهید token را در یک فرم در وب‌سایت شما همراه با رمز عبور جدید وارد کند و از JavaScript برای ارسال محتوای فرم به endpoint PUT /v1/users/password خود استفاده کنید. ایمیل برای پشتیبانی از این فرآیند می‌تواند شبیه این باشد:

Hi,

To reset your password please visit https://example.com/users/password and enter the 
following secret code along with your new password:

--------------------------
Y7QCRZ7FWOWYLXLAOC2VYOLIPY
--------------------------

Please note that this code will expire in 45 minutes. If you need another code please 
visit https://example.com/tokens/password-reset.

Thanks,

The Greenlight Team

یا اگر نمی‌خواهید کاربر token را کپی و پیست کند، می‌توانید از آن بخواهید روی لینکی حاوی token کلیک کند که او را به صفحه‌ای در وب‌سایت شما هدایت کند. مشابه این:

Hi,

To reset your password please click the following link:

https://example.com/users/password?token=Y7QCRZ7FWOWYLXLAOC2VYOLIPY

Please note that this link will expire in 45 minutes. If you need another password reset
link please visit https://example.com/tokens/password-reset.

Thanks,

The Greenlight Team

این صفحه سپس می‌تواند فرمی را نمایش دهد که در آن کاربر رمز عبور جدید خود را وارد می‌کند و JavaScript موجود در صفحه باید token را از URL استخراج کرده و آن را همراه با رمز عبور جدید به endpoint PUT /v1/users/password ارسال کند. دوباره، اگر این گزینه دوم را انتخاب می‌کنید، باید اقداماتی برای جلوگیری از نشت token در referrer header انجام دهید.