Let's Go تمرین‌های راهنما › پیاده‌سازی قابلیت «تغییر رمز عبور»
قبلی · فهرست · بعدی
فصل ۱۶.۶.

پیاده‌سازی قابلیت «تغییر رمز عبور»

هدف شما در این تمرین، افزودن امکان تغییر رمز عبور برای کاربر احراز هویت شده است، با استفاده از فرمی که شبیه به این است:

16.06-01.png

در طول این تمرین باید مطمئن شوید که:

مرحله 1

دو route و handler جدید ایجاد کنید:

هر دو مسیر باید فقط برای کاربران احراز هویت شده محدود شوند.

نمایش کد پیشنهادی

مرحله 2

یک فایل جدید ui/html/pages/password.tmpl ایجاد کنید که شامل فرم تغییر رمز عبور است. این فرم باید:

راهنما: ممکن است بخواهید از کاری که روی فرم ثبت‌نام کاربر انجام دادیم به عنوان راهنما استفاده کنید.

سپس فایل cmd/web/handlers.go را به‌روزرسانی کنید تا شامل یک struct جدید accountPasswordUpdateForm باشد که بتوانید داده‌های فرم را در آن پارس کنید، و handler accountPasswordUpdate را به‌روزرسانی کنید تا این فرم خالی را نمایش دهد.

وقتی به عنوان یک کاربر احراز هویت شده به https://localhost:4000/account/password/update مراجعه می‌کنید، باید شبیه به این باشد:

16.06-02.png

نمایش کد پیشنهادی

مرحله 3

handler accountPasswordUpdatePost را به‌روزرسانی کنید تا بررسی‌های اعتبارسنجی فرم زیر را انجام دهد، و در صورت هرگونه خطا، فرم را با پیام‌های خطای مربوطه دوباره نمایش دهد.

16.06-03.png

نمایش کد پیشنهادی

مرحله 4

در فایل internal/models/users.go خود یک متد جدید UserModel.PasswordUpdate() با امضای زیر ایجاد کنید:

func (m *UserModel) PasswordUpdate(id int, currentPassword, newPassword string) error

در این متد:

  1. جزئیات کاربر را برای کاربری با شناسه داده شده توسط پارامتر id از پایگاه داده بازیابی کنید.
  2. بررسی کنید که مقدار currentPassword با رمز عبور هش شده کاربر مطابقت دارد. اگر مطابقت نداشت، یک خطای ErrInvalidCredentials برگردانید.
  3. در غیر این صورت، مقدار newPassword را هش کنید و ستون hashed_password در جدول users را برای کاربر مربوطه به‌روزرسانی کنید.

همچنین نوع interface UserModelInterface را به‌روزرسانی کنید تا متد PasswordUpdate() که تازه ایجاد کردید را شامل شود.

نمایش کد پیشنهادی

مرحله 5

handler accountPasswordUpdatePost را به‌روزرسانی کنید تا اگر فرم معتبر باشد، متد UserModel.PasswordUpdate() را فراخوانی کند (به یاد داشته باشید، شناسه کاربر باید در داده‌های نشست باشد).

در صورت بروز خطای models.ErrInvalidCredentials، به کاربر اطلاع دهید که مقدار اشتباهی در فیلد فرم currentPassword وارد کرده است. در غیر این صورت، یک پیام flash به نشست کاربر اضافه کنید که می‌گوید رمز عبور آن‌ها با موفقیت تغییر کرده است و آن‌ها را به صفحه حساب کاربری‌شان هدایت کنید.

نمایش کد پیشنهادی

مرحله 6

صفحه حساب کاربری را به‌روزرسانی کنید تا شامل لینکی به فرم تغییر رمز عبور باشد، مشابه این:

16.06-04.png

نمایش کد پیشنهادی

مرحله 7

سعی کنید تست‌های برنامه را اجرا کنید. باید یک خطا دریافت کنید چون نوع mocks.UserModel دیگر interface مشخص شده در struct models.UserModelInterface را برآورده نمی‌کند. این را با افزودن متد مناسب PasswordUpdate() به mock برطرف کنید و مطمئن شوید که تست‌ها پاس می‌شوند.

نمایش کد پیشنهادی

کد پیشنهادی

کد پیشنهادی برای مرحله 1

File: cmd/web/handlers.go
package main

...

func (app *application) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) {
    // Some code will go here later...
}

func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) {
    // Some code will go here later...
}
File: cmd/web/routes.go
package main

...

func (app *application) routes() http.Handler {
    mux := http.NewServeMux()

    mux.Handle("GET /static/", http.FileServerFS(ui.Files))

    mux.HandleFunc("GET /ping", ping)

    dynamic := alice.New(app.sessionManager.LoadAndSave, noSurf, app.authenticate)

    mux.Handle("GET /{$}", dynamic.ThenFunc(app.home))
    mux.Handle("GET /about", dynamic.ThenFunc(app.about))
    mux.Handle("GET /snippet/view/{id}", dynamic.ThenFunc(app.snippetView))
    mux.Handle("GET /user/signup", dynamic.ThenFunc(app.userSignup))
    mux.Handle("POST /user/signup", dynamic.ThenFunc(app.userSignupPost))
    mux.Handle("GET /user/login", dynamic.ThenFunc(app.userLogin))
    mux.Handle("POST /user/login", dynamic.ThenFunc(app.userLoginPost))

    protected := dynamic.Append(app.requireAuthentication)

    mux.Handle("GET /snippet/create", protected.ThenFunc(app.snippetCreate))
    mux.Handle("POST /snippet/create", protected.ThenFunc(app.snippetCreatePost))
    mux.Handle("GET /account/view", protected.ThenFunc(app.accountView))
    // Add the two new routes, restricted to authenticated users only.
    mux.Handle("GET /account/password/update", protected.ThenFunc(app.accountPasswordUpdate))
    mux.Handle("POST /account/password/update", protected.ThenFunc(app.accountPasswordUpdatePost))
    mux.Handle("POST /user/logout", protected.ThenFunc(app.userLogoutPost))

    standard := alice.New(app.recoverPanic, app.logRequest, commonHeaders)
    return standard.Then(mux)
}

کد پیشنهادی برای مرحله 2

File: ui/html/pages/password.tmpl
{{define "title"}}Change Password{{end}}

{{define "main"}}
<h2>Change Password</h2>
<form action='/account/password/update' method='POST' novalidate>
    <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
    <div>
        <label>Current password:</label>
        {{with .Form.FieldErrors.currentPassword}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='currentPassword'>
    </div>
    <div>
        <label>New password:</label>
        {{with .Form.FieldErrors.newPassword}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='newPassword'>
    </div>
    <div>
        <label>Confirm new password:</label>
        {{with .Form.FieldErrors.newPasswordConfirmation}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='newPasswordConfirmation'>
    </div>
    <div>
        <input type='submit' value='Change password'>
    </div>
</form>
{{end}}
File: cmd/web/handlers.go
package main

...

type accountPasswordUpdateForm struct {
    CurrentPassword         string `form:"currentPassword"`
    NewPassword             string `form:"newPassword"`
    NewPasswordConfirmation string `form:"newPasswordConfirmation"`
    validator.Validator     `form:"-"`
}

func (app *application) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) {
    data := app.newTemplateData(r)
    data.Form = accountPasswordUpdateForm{}

    app.render(w, r, http.StatusOK, "password.tmpl", data)
}

...

کد پیشنهادی برای مرحله 3

File: cmd/web/handlers.go
package main

...

func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) {
    var form accountPasswordUpdateForm

    err := app.decodePostForm(r, &form)
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    form.CheckField(validator.NotBlank(form.CurrentPassword), "currentPassword", "This field cannot be blank")
    form.CheckField(validator.NotBlank(form.NewPassword), "newPassword", "This field cannot be blank")
    form.CheckField(validator.MinChars(form.NewPassword, 8), "newPassword", "This field must be at least 8 characters long")
    form.CheckField(validator.NotBlank(form.NewPasswordConfirmation), "newPasswordConfirmation", "This field cannot be blank")
    form.CheckField(form.NewPassword == form.NewPasswordConfirmation, "newPasswordConfirmation", "Passwords do not match")

    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form

        app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data)
        return
    }
}

کد پیشنهادی برای مرحله 4

File: internal/models/users.go
package models

...

type UserModelInterface interface {
    Insert(name, email, password string) error
    Authenticate(email, password string) (int, error)
    Exists(id int) (bool, error)
    Get(id int) (User, error)
    PasswordUpdate(id int, currentPassword, newPassword string) error
}

...

func (m *UserModel) PasswordUpdate(id int, currentPassword, newPassword string) error {
    var currentHashedPassword []byte

    stmt := "SELECT hashed_password FROM users WHERE id = ?"

    err := m.DB.QueryRow(stmt, id).Scan(&currentHashedPassword)
    if err != nil {
        return err
    }

    err = bcrypt.CompareHashAndPassword(currentHashedPassword, []byte(currentPassword))
    if err != nil {
        if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
            return ErrInvalidCredentials
        } else {
            return err
        }
    }

    newHashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
    if err != nil {
        return err
    }

    stmt = "UPDATE users SET hashed_password = ? WHERE id = ?"

    _, err = m.DB.Exec(stmt, string(newHashedPassword), id)
    return err
}

کد پیشنهادی برای مرحله 5

File: cmd/web/handlers.go
package main

...

func (app *application) accountPasswordUpdatePost(w http.ResponseWriter, r *http.Request) {
    var form accountPasswordUpdateForm

    err := app.decodePostForm(r, &form)
    if err != nil {
        app.clientError(w, http.StatusBadRequest)
        return
    }

    form.CheckField(validator.NotBlank(form.CurrentPassword), "currentPassword", "This field cannot be blank")
    form.CheckField(validator.NotBlank(form.NewPassword), "newPassword", "This field cannot be blank")
    form.CheckField(validator.MinChars(form.NewPassword, 8), "newPassword", "This field must be at least 8 characters long")
    form.CheckField(validator.NotBlank(form.NewPasswordConfirmation), "newPasswordConfirmation", "This field cannot be blank")
    form.CheckField(form.NewPassword == form.NewPasswordConfirmation, "newPasswordConfirmation", "Passwords do not match")

    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form

        app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data)
        return
    }

    userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")

    err = app.users.PasswordUpdate(userID, form.CurrentPassword, form.NewPassword)
    if err != nil {
        if errors.Is(err, models.ErrInvalidCredentials) {
            form.AddFieldError("currentPassword", "Current password is incorrect")

            data := app.newTemplateData(r)
            data.Form = form

            app.render(w, r, http.StatusUnprocessableEntity, "password.tmpl", data)
        } else {
            app.serverError(w, r, err)
        }
        return
    }

    app.sessionManager.Put(r.Context(), "flash", "Your password has been updated!")

    http.Redirect(w, r, "/account/view", http.StatusSeeOther)
}

کد پیشنهادی برای مرحله 6

File: ui/html/pages/account.tmpl
{{define "title"}}Your Account{{end}}

{{define "main"}}
    <h2>Your Account</h2>
    {{with .User}}
     <table>
        <tr>
            <th>Name</th>
            <td>{{.Name}}</td>
        </tr>
        <tr>
            <th>Email</th>
            <td>{{.Email}}</td>
        </tr>
        <tr>
            <th>Joined</th>
            <td>{{humanDate .Created}}</td>
        </tr>
        <tr>
            <!-- Add a link to the change password form -->
            <th>Password</th>
            <td><a href="/account/password/update">Change password</a></td>
        </tr>
    </table>
    {{end }}
{{end}}

کد پیشنهادی برای مرحله 7

$ go test ./...
# snippetbox.alexedwards.net/cmd/web [snippetbox.alexedwards.net/cmd/web.test]
cmd/web/testutils_test.go:48:19: cannot use &mocks.UserModel{} (value of type *mocks.UserModel) as type models.UserModelInterface in struct literal:
        *mocks.UserModel does not implement models.UserModelInterface (missing PasswordUpdate method)
FAIL    snippetbox.alexedwards.net/cmd/web [build failed]
ok      snippetbox.alexedwards.net/internal/models      1.099s
?       snippetbox.alexedwards.net/internal/models/mocks        [no test files]
?       snippetbox.alexedwards.net/internal/validator   [no test files]
?       snippetbox.alexedwards.net/ui   [no test files]
FAIL
File: internal/models/mock/users.go
package mocks

...

func (m *UserModel) PasswordUpdate(id int, currentPassword, newPassword string) error {
    if id == 1 {
        if currentPassword != "pa$$word" {
            return models.ErrInvalidCredentials
        }

        return nil
    }

    return models.ErrNoRecord
}
$ go test ./...
ok      snippetbox.alexedwards.net/cmd/web      0.026s
ok      snippetbox.alexedwards.net/internal/models      (cached)
?       snippetbox.alexedwards.net/internal/models/mocks        [no test files]
?       snippetbox.alexedwards.net/internal/validator   [no test files]
?       snippetbox.alexedwards.net/ui   [no test files]