Let's Go احراز هویت کاربر › ورود کاربر
قبلی · فهرست · بعدی
فصل 10.4.

ورود کاربر (User Login)

در این بخش، نحوه پیاده‌سازی ورود کاربر (User Login) را بررسی می‌کنیم. این شامل فرم ورود (Login Form)، احراز هویت (Authentication) و مدیریت نشست (Session Management) می‌شود.

برای شروع، بیایید یک فرم HTML (HTML Form) برای ورود کاربر (User Login) ایجاد کنیم:

در این فصل قصد داریم روی ایجاد صفحه ورود کاربر برای برنامه خود تمرکز کنیم.

قبل از اینکه به بخش اصلی این کار بپردازیم، بیایید به سرعت به بسته internal/validator که قبلاً ساخته‌ایم نگاهی بیندازیم و آن را به‌روزرسانی کنیم تا از خطاهای اعتبارسنجی که به یک فیلد خاص فرم مرتبط نیستند پشتیبانی کند.

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

لطفاً فایل internal/validator/validator.go خود را به صورت زیر به‌روزرسانی کنید:

File: internal/validator/validator.go
package validator

...

// Add a new NonFieldErrors []string field to the struct, which we will use to 
// hold any validation errors which are not related to a specific form field.
type Validator struct {
    NonFieldErrors []string
    FieldErrors    map[string]string
}

// Update the Valid() method to also check that the NonFieldErrors slice is
// empty.
func (v *Validator) Valid() bool {
    return len(v.FieldErrors) == 0 && len(v.NonFieldErrors) == 0
}

// Create an AddNonFieldError() helper for adding error messages to the new
// NonFieldErrors slice.
func (v *Validator) AddNonFieldError(message string) {
    v.NonFieldErrors = append(v.NonFieldErrors, message)
}

...

بعد، بیایید یک قالب جدید ui/html/pages/login.tmpl ایجاد کنیم که حاوی نشانه‌گذاری برای صفحه ورود ما باشد. ما از همان الگوی نمایش خطاهای اعتبارسنجی و نمایش مجدد داده‌ها که برای صفحه ثبت‌نام خود استفاده کردیم، پیروی خواهیم کرد.

$ touch ui/html/pages/login.tmpl
File: ui/html/pages/login.tmpl
{{define "title"}}Login{{end}}

{{define "main"}}
<form action='/user/login' method='POST' novalidate>
    <!-- Notice that here we are looping over the NonFieldErrors and displaying
    them, if any exist -->
    {{range .Form.NonFieldErrors}}
        <div class='error'>{{.}}</div>
    {{end}}
    <div>
        <label>Email:</label>
        {{with .Form.FieldErrors.email}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='email' name='email' value='{{.Form.Email}}'>
    </div>
    <div>
        <label>Password:</label>
        {{with .Form.FieldErrors.password}}
            <label class='error'>{{.}}</label>
        {{end}}
        <input type='password' name='password'>
    </div>
    <div>
        <input type='submit' value='Login'>
    </div>
</form>
{{end}}

سپس به فایل cmd/web/handlers.go خود بروید و یک ساختار جدید userLoginForm ایجاد کنید (برای نمایش و نگهداری داده‌های فرم)، و handler userLogin خود را برای نمایش صفحه ورود تطبیق دهید.

به این صورت:

File: cmd/web/handlers.go
package main

...

// Create a new userLoginForm struct.
type userLoginForm struct {
    Email               string `form:"email"`
    Password            string `form:"password"`
    validator.Validator `form:"-"`
}

// Update the handler so it displays the login page.
func (app *application) userLogin(w http.ResponseWriter, r *http.Request) {
    data := app.newTemplateData(r)
    data.Form = userLoginForm{}
    app.render(w, r, http.StatusOK, "login.tmpl", data)
}

...

اگر برنامه را اجرا کنید و به https://localhost:4000/user/login بروید، اکنون باید صفحه ورود را به این شکل ببینید:

10.04-01.png

تأیید جزئیات کاربر

گام بعدی بخش جالب است: چگونه می‌توانیم تأیید کنیم که ایمیل و رمز عبور ارسال شده توسط کاربر صحیح است؟

بخش اصلی این منطق تأیید در متد UserModel.Authenticate() مدل کاربر ما انجام خواهد شد. به طور خاص، ما به دو چیز نیاز داریم:

  1. ابتدا باید رمز عبور هش شده مرتبط با آدرس ایمیل را از جدول MySQL users بازیابی کنیم. اگر ایمیل در پایگاه داده وجود نداشته باشد، خطای ErrInvalidCredentials را که قبلاً ساخته‌ایم برمی‌گردانیم.

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

بیایید دقیقاً این کار را انجام دهیم. کد زیر را به فایل internal/models/users.go خود اضافه کنید:

File: internal/models/users.go
package models

...

func (m *UserModel) Authenticate(email, password string) (int, error) {
    // Retrieve the id and hashed password associated with the given email. If
    // no matching email exists we return the ErrInvalidCredentials error.
    var id int
    var hashedPassword []byte

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

    err := m.DB.QueryRow(stmt, email).Scan(&id, &hashedPassword)
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            return 0, ErrInvalidCredentials
        } else {
            return 0, err
        }
    }

    // Check whether the hashed password and plain-text password provided match.
    // If they don't, we return the ErrInvalidCredentials error.
    err = bcrypt.CompareHashAndPassword(hashedPassword, []byte(password))
    if err != nil {
        if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
            return 0, ErrInvalidCredentials
        } else {
            return 0, err
        }
    }

    // Otherwise, the password is correct. Return the user ID.
    return id, nil
}

گام بعدی ما شامل به‌روزرسانی handler userLoginPost است تا داده‌های فرم ورود ارسال شده را تجزیه کند و این متد UserModel.Authenticate() را فراخوانی کند.

اگر جزئیات ورود معتبر باشند، سپس می‌خواهیم id کاربر را به داده‌های جلسه آنها اضافه کنیم تا — برای درخواست‌های آینده — بدانیم که آنها با موفقیت احراز هویت شده‌اند و کدام کاربر هستند.

به فایل handlers.go خود بروید و آن را به صورت زیر به‌روزرسانی کنید:

File: cmd/web/handlers.go
package main

...

func (app *application) userLoginPost(w http.ResponseWriter, r *http.Request) {
    // Decode the form data into the userLoginForm struct.
    var form userLoginForm

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

    // Do some validation checks on the form. We check that both email and
    // password are provided, and also check the format of the email address as
    // a UX-nicety (in case the user makes a typo).
    form.CheckField(validator.NotBlank(form.Email), "email", "This field cannot be blank")
    form.CheckField(validator.Matches(form.Email, validator.EmailRX), "email", "This field must be a valid email address")
    form.CheckField(validator.NotBlank(form.Password), "password", "This field cannot be blank")

    if !form.Valid() {
        data := app.newTemplateData(r)
        data.Form = form
        app.render(w, r, http.StatusUnprocessableEntity, "login.tmpl", data)
        return
    }

    // Check whether the credentials are valid. If they're not, add a generic
    // non-field error message and re-display the login page.
    id, err := app.users.Authenticate(form.Email, form.Password)
    if err != nil {
        if errors.Is(err, models.ErrInvalidCredentials) {
            form.AddNonFieldError("Email or password is incorrect")

            data := app.newTemplateData(r)
            data.Form = form
            app.render(w, r, http.StatusUnprocessableEntity, "login.tmpl", data)
        } else {
            app.serverError(w, r, err)
        }
        return
    }

    // Use the RenewToken() method on the current session to change the session
    // ID. It's good practice to generate a new session ID when the 
    // authentication state or privilege levels changes for the user (e.g. login
    // and logout operations).
    err = app.sessionManager.RenewToken(r.Context())
    if err != nil {
        app.serverError(w, r, err)
        return
    }

    // Add the ID of the current user to the session, so that they are now
    // 'logged in'.
    app.sessionManager.Put(r.Context(), "authenticatedUserID", id)

    // Redirect the user to the create snippet page.
    http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
}

...

بسیار خوب، بیایید این را امتحان کنیم!

برنامه را مجدداً راه‌اندازی کنید و سعی کنید برخی از اعتبارنامه‌های کاربر نامعتبر را ارسال کنید…

10.04-02.png

باید یک پیام خطای اعتبارسنجی غیر فیلدی دریافت کنید که به این شکل است:

10.04-03.png

اما وقتی برخی از اعتبارنامه‌های صحیح را وارد می‌کنید (از آدرس ایمیل و رمز عبور برای کاربری که در فصل قبلی ایجاد کرده‌اید استفاده کنید)، برنامه باید شما را وارد کند و به صفحه ایجاد قطعه هدایت کند، به این صورت:

10.04-04.png
10.04-05.png

ما در دو فصل گذشته کارهای زیادی انجام داده‌ایم، بنابراین بیایید به سرعت بررسی کنیم که وضعیت چگونه است.

واژه‌نامه اصطلاحات فنی

اصطلاح فارسی معادل انگلیسی توضیح
ورود کاربر User Login فرآیند دسترسی به حساب کاربری
فرم ورود Login Form فرم ورود اطلاعات کاربر
احراز هویت Authentication تأیید هویت کاربر
مدیریت نشست Session Management کنترل نشست‌های کاربری
فرم HTML HTML Form فرم ورود اطلاعات
اعتبارسنجی Validation بررسی صحت اطلاعات
رمز عبور Password کلمه عبور کاربر
کوکی نشست Session Cookie کوکی نگهداری اطلاعات نشست
خطای ورود Login Error پیام خطای ورود ناموفق
امنیت Security حفاظت از حساب کاربری