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

ثبت‌نام کاربر

حالا که مقدمات را فراهم کردیم، بیایید شروع کنیم و از آن‌ها استفاده کنیم. اولین قدم ایجاد یک endpoint جدید API برای مدیریت فرآیند ثبت‌نام (یا عضویت) یک کاربر جدید است.

متد الگوی URL Handler عملیات
GET /v1/healthcheck healthcheckHandler نمایش اطلاعات برنامه
GET /v1/movies listMoviesHandler نمایش جزئیات تمام فیلم‌ها
POST /v1/movies createMovieHandler ایجاد یک فیلم جدید
GET /v1/movies/:id showMovieHandler نمایش جزئیات یک فیلم خاص
PATCH /v1/movies/:id updateMovieHandler بروزرسانی جزئیات یک فیلم خاص
DELETE /v1/movies/:id deleteMovieHandler حذف یک فیلم خاص
POST /v1/users registerUserHandler ثبت‌نام کاربر جدید

وقتی یک کلاینت این endpoint جدید POST /v1/users را فراخوانی می‌کند، انتظار داریم جزئیات زیر را برای کاربر جدید در یک request body با فرمت JSON ارسال کند. چیزی شبیه به این:

{
    "name": "Alice Smith",
    "email": "alice@example.com",
    "password": "pa55word"
}

وقتی این درخواست را دریافت کردیم، registerUserHandler باید یک struct جدید User حاوی این جزئیات ایجاد کند، آن را با تابع کمکی ValidateUser() اعتبارسنجی کند و سپس آن را به متد UserModel.Insert() پاس دهد تا یک رکورد جدید در پایگاه داده ایجاد شود.

در واقع، ما قبلاً بیشتر کد مورد نیاز برای registerUserHandler را نوشته‌ایم — اکنون فقط کافی است آن‌ها را به ترتیب درست کنار هم قرار دهیم.

اگر می‌خواهید همراه ما پیش بروید، یک فایل جدید cmd/api/users.go ایجاد کنید:

$ touch cmd/api/users.go

و سپس متد جدید registerUserHandler حاوی کد زیر را اضافه کنید:

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

import (
    "errors"
    "net/http"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/validator"
)

func (app *application) registerUserHandler(w http.ResponseWriter, r *http.Request) {
    // Create an anonymous struct to hold the expected data from the request body.
    var input struct {
        Name     string `json:"name"`
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    // Parse the request body into the anonymous struct.
    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    // Copy the data from the request body into a new User struct. Notice also that we
    // set the Activated field to false, which isn't strictly necessary because the 
    // Activated field will have the zero value of false by default. But setting this 
    // explicitly helps to make our intentions clear to anyone reading the code.
    user := &data.User{
        Name:      input.Name,
        Email:     input.Email,
        Activated: false,
    }

    // Use the Password.Set() method to generate and store the hashed and plaintext 
    // passwords.
    err = user.Password.Set(input.Password)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    v := validator.New()

    // Validate the user struct and return the error messages to the client if any of 
    // the checks fail.
    if data.ValidateUser(v, user); !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    // Insert the user data into the database.
    err = app.models.Users.Insert(user)
    if err != nil {
        switch {
        // If we get a ErrDuplicateEmail error, use the v.AddError() method to manually
        // add a message to the validator instance, and then call our 
        // failedValidationResponse() helper.
        case errors.Is(err, data.ErrDuplicateEmail):
            v.AddError("email", "a user with this email address already exists")
            app.failedValidationResponse(w, r, v.Errors)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // Write a JSON response containing the user data along with a 201 Created status 
    // code.
    err = app.writeJSON(w, http.StatusCreated, envelope{"user": user}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

قبل از اینکه این را امتحان کنیم، باید endpoint جدید POST /v1/users را به فایل 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.listMoviesHandler)
    router.HandlerFunc(http.MethodPost, "/v1/movies", app.createMovieHandler)
    router.HandlerFunc(http.MethodGet, "/v1/movies/:id", app.showMovieHandler)
    router.HandlerFunc(http.MethodPatch, "/v1/movies/:id", app.updateMovieHandler)
    router.HandlerFunc(http.MethodDelete, "/v1/movies/:id", app.deleteMovieHandler)

    // Add the route for the POST /v1/users endpoint.
    router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)

    return app.recoverPanic(app.rateLimit(router))
}

وقتی این کار تمام شد، مطمئن شوید که تمام فایل‌ها ذخیره شده‌اند و API را راه‌اندازی کنید.

سپس یک درخواست به endpoint POST /v1/users ارسال کنید تا یک کاربر جدید با آدرس ایمیل alice@example.com ثبت‌نام کند. باید یک پاسخ 201 Created دریافت کنید که جزئیات کاربر را نمایش دهد، چیزی شبیه به این:

$ BODY='{"name": "Alice Smith", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 201 Created
Content-Type: application/json
Date: Mon, 15 Mar 2021 14:42:58 GMT
Content-Length: 152

{
    "user": {
        "id": 1,
        "created_at": "2021-03-15T15:42:58+01:00",
        "name": "Alice Smith",
        "email": "alice@example.com",
        "activated": false
    }
}

وضعیت خوب به نظر می‌رسد. از کد وضعیت می‌توانیم ببینیم که رکورد کاربر با موفقیت ایجاد شده است، و در پاسخ JSON می‌توانیم اطلاعات تولید شده توسط سیستم برای کاربر جدید را ببینیم — از جمله شناسه کاربر و وضعیت فعال‌سازی.

اگر به پایگاه داده PostgreSQL خود نگاه کنید، باید رکورد جدید را در جدول users ببینید. چیزی شبیه به این:

$ psql $GREENLIGHT_DB_DSN
Password for user greenlight: 
psql (15.4 (Ubuntu 15.4-1.pgdg22.04+1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

greenlight=> SELECT * FROM users;
 id |       created_at       |    name     |       email       |           password_hash             | activated | version 
----+------------------------+-------------+-------------------+-------------------------------------+-----------+---------
  1 | 2021-04-11 14:29:45+02 | Alice Smith | alice@example.com | \x24326124313224526157784d67356d... | f         |       1
(1 row)

خب، بیایید یک درخواست دیگر به API خود ارسال کنیم اما با جزئیات کاربر نامعتبر. این بار بررسی‌های اعتبارسنجی ما فعال خواهند شد و کلاینت باید پیام‌های خطای مربوطه را دریافت کند. به عنوان مثال:

$ BODY='{"name": "", "email": "bob@invalid.", "password": "pass"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
    "error": {
        "email": "must be a valid email address",
        "name": "must be provided",
        "password": "must be at least 8 bytes long"
    }
}

در نهایت، سعی کنید یک حساب دوم برای alice@example.com ثبت‌نام کنید. این بار باید یک خطای اعتبارسنجی حاوی پیام “a user with this email address already exists” دریافت کنید، به این شکل:

$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -i -d "$BODY" localhost:4000/v1/users
HTTP/1.1 422 Unprocessable Entity
Cache-Control: no-store
Content-Type: application/json
Date: Wed, 30 Dec 2020 14:22:06 GMT
Content-Length: 78

{
    "error": {
        "email": "a user with this email address already exists"
    }
}

اگر می‌خواهید، می‌توانید درخواست‌هایی با حروف بزرگ و کوچک متفاوت alice@example.com ارسال کنید — مانند ALICE@example.com یا Alice@Example.com. از آنجا که ستون email در پایگاه داده ما نوع citext دارد، این نسخه‌های جایگزین نیز به عنوان تکراری شناسایی خواهند شد.


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

حساسیت به بزرگی و کوچکی حروف در ایمیل

بیایید به طور خلاصه در مورد حساسیت به بزرگی و کوچکی حروف در آدرس‌های ایمیل بیشتر صحبت کنیم.

پس، این برای برنامه ما چه معنایی دارد؟

از نظر امنیتی، ما همیشه باید آدرس ایمیل را دقیقاً با همان حروفی که کاربر در هنگام ثبت‌نام ارائه کرده ذخیره کنیم، و باید فقط با همان حروف دقیق به آن‌ها ایمیل ارسال کنیم. اگر این کار را نکنیم، خطر این وجود دارد که ایمیل‌ها به کاربر واقعی اشتباهی تحویل داده شوند. آگاهی از این موضوع به ویژه در فرآیندهایی که از ایمیل برای اهداف احراز هویت استفاده می‌کنند، مانند فرآیند بازنشانی رمز عبور، بسیار مهم است.

با این حال، از آنجا که alice@example.com و ALICE@example.com به احتمال زیاد یک کاربر هستند، ما باید به طور کلی آدرس‌های ایمیل را برای مقاصد مقایسه‌ای به عنوان حساس به بزرگی و کوچکی حروف در نظر نگیریم.

در فرآیند ثبت‌نام ما، استفاده از مقایسه غیرحساس به بزرگی و کوچکی حروف از ثبت‌نام تصادفی (یا عمدی) چندین حساب توسط کاربران فقط با استفاده از حروف متفاوت جلوگیری می‌کند. و از نظر تجربه کاربری، در فرآیندهایی مانند ورود، فعال‌سازی یا بازنشانی رمز عبور، برای کاربران بخشنده‌تر است اگر نیازی نباشند درخواست خود را دقیقاً با همان حروف ایمیلی که در هنگام ثبت‌نام استفاده کرده‌اند ارسال کنند.

شمارش کاربران

آگاهی از این موضوع مهم است که endpoint ثبت‌نام ما در برابر شمارش کاربران آسیب‌پذیر است. به عنوان مثال، اگر یک مهاجم بخواهد بداند آیا alice@example.com حسابی نزد ما دارد یا خیر، کافی است درخواستی مانند زیر ارسال کند:

$ BODY='{"name": "Alice Jones", "email": "alice@example.com", "password": "pa55word"}'
$ curl -d "$BODY" localhost:4000/v1/users
{
    "error": {
        "email": "a user with this email address already exists"
    }
}

و پاسخ را همانجا دریافت می‌کند. ما به وضوح به مهاجم اعلام می‌کنیم که alice@example.com از قبل یک کاربر است.

پس، خطرات افشای این اطلاعات چیست؟

اولین و بدیهی‌ترین خطر مربوط به حریم خصوصی کاربران است. برای سرویس‌های حساس یا محرمانه، احتمالاً نمی‌خواهید مشخص باشد چه کسی حساب دارد. خطر دوم این است که به مهاجم اجازه می‌دهد حساب کاربری را راحت‌تر به خطر بیندازد. وقتی آدرس ایمیل یک کاربر را بدانند، می‌توانند بالقوه:

جلوگیری از حملات شمارش معمولاً به دو چیز نیاز دارد:

  1. اطمینان از اینکه پاسخ ارسال شده به کلاینت همیشه دقیقاً یکسان باشد، صرف نظر از اینکه کاربر وجود داشته باشد یا نه. به طور کلی، این به معنای تغییر متن پاسخ به صورت مبهم و اطلاع‌رسانی به کاربر درباره مشکلات احتمالی از طریق یک کانال جانبی (مانند ارسال ایمیل به آن‌ها برای اطلاع‌رسانی که از قبل حساب دارند) است.
  2. اطمینان از اینکه زمان ارسال پاسخ همیشه یکسان باشد، صرف نظر از اینکه کاربر وجود داشته باشد یا نه. در Go، این به طور کلی به معنای واگذاری کار به یک goroutine پس‌زمینه است.

متأسفانه، این تخفیف‌ها معمولاً پیچیدگی برنامه شما را افزایش می‌دهند و اصطکاک و ابهام را به فرآیندهای شما اضافه می‌کنند. برای تمام کاربران عادی شما که مهاجم نیستند، این از نظر UX یک نقطه منفی است. باید بپرسید: آیا ارزش این مبادله را دارد؟

چند نکته برای فکر کردن هنگام پاسخ به این سؤال وجود دارد. حریم خصوصی کاربران در برنامه شما چقدر مهم است؟ یک حساب به خطر افتاده برای مهاجم چقدر جذاب (ارزشمند) است؟ کاهش اصطکاک در فرآیندهای کاربری شما چقدر مهم است؟ پاسخ‌های این سؤالات از پروژه‌ای به پروژه دیگر متفاوت خواهند بود و به شکل‌گیری تصمیم شما کمک خواهند کرد.

ارزش اشاره کردن دارد که بسیاری از سرویس‌های بزرگ، از جمله Twitter، GitHub و Amazon، از شمارش کاربران جلوگیری نمی‌کنند (حداقل در صفحات ثبت‌نام خود). من پیشنهاد نمی‌کنم که این موضوع قابل قبول است — فقط آن شرکت‌ها تصمیم گرفته‌اند که اصطکاک اضافی برای کاربران بدتر از خطرات حریم خصوصی و امنیتی در مورد خاص آن‌ها است.