ثبتنام کاربر
حالا که مقدمات را فراهم کردیم، بیایید شروع کنیم و از آنها استفاده کنیم. اولین قدم ایجاد یک 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 حاوی کد زیر را اضافه کنید:
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 اضافه کنیم. به این شکل:
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 دارد، این نسخههای جایگزین نیز به عنوان تکراری شناسایی خواهند شد.
اطلاعات تکمیلی
حساسیت به بزرگی و کوچکی حروف در ایمیل
بیایید به طور خلاصه در مورد حساسیت به بزرگی و کوچکی حروف در آدرسهای ایمیل بیشتر صحبت کنیم.
به لطف مشخصات RFC 2821، بخش domain آدرس ایمیل (
username@domain) به بزرگی و کوچکی حروف حساس نیست. این بدان معناست که میتوانیم مطمئن باشیم کاربر واقعی پشتalice@example.comهمان شخصی است که پشتalice@EXAMPLE.COMاست.بخش username آدرس ایمیل ممکن است به بزرگی و کوچکی حروف حساس باشد یا نباشد — این بستگی به ارائهدهنده ایمیل دارد. تقریباً تمام ارائهدهندگان ایمیل بزرگ، username را به بزرگی و کوچکی حروف حساس نمیدانند، اما این تضمین شده نیست. تنها چیزی که میتوانیم بگوییم این است که کاربر واقعی پشت آدرس
alice@example.comبه احتمال زیاد (اما نه قطعاً) همان شخصی است که پشتALICE@example.comاست.
پس، این برای برنامه ما چه معنایی دارد؟
از نظر امنیتی، ما همیشه باید آدرس ایمیل را دقیقاً با همان حروفی که کاربر در هنگام ثبتنام ارائه کرده ذخیره کنیم، و باید فقط با همان حروف دقیق به آنها ایمیل ارسال کنیم. اگر این کار را نکنیم، خطر این وجود دارد که ایمیلها به کاربر واقعی اشتباهی تحویل داده شوند. آگاهی از این موضوع به ویژه در فرآیندهایی که از ایمیل برای اهداف احراز هویت استفاده میکنند، مانند فرآیند بازنشانی رمز عبور، بسیار مهم است.
با این حال، از آنجا که 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 از قبل یک کاربر است.
پس، خطرات افشای این اطلاعات چیست؟
اولین و بدیهیترین خطر مربوط به حریم خصوصی کاربران است. برای سرویسهای حساس یا محرمانه، احتمالاً نمیخواهید مشخص باشد چه کسی حساب دارد. خطر دوم این است که به مهاجم اجازه میدهد حساب کاربری را راحتتر به خطر بیندازد. وقتی آدرس ایمیل یک کاربر را بدانند، میتوانند بالقوه:
- کاربر را از طریق مهندسی اجتماعی یا نوع دیگری از حملات هدفمند برای به دست آوردن رمز عبورش هدف قرار دهند.
- آدرس ایمیل را در جداول رمزهای عبور لو رفته جستجو کنند و همان رمزهای عبور را روی سرویس ما امتحان کنند.
جلوگیری از حملات شمارش معمولاً به دو چیز نیاز دارد:
- اطمینان از اینکه پاسخ ارسال شده به کلاینت همیشه دقیقاً یکسان باشد، صرف نظر از اینکه کاربر وجود داشته باشد یا نه. به طور کلی، این به معنای تغییر متن پاسخ به صورت مبهم و اطلاعرسانی به کاربر درباره مشکلات احتمالی از طریق یک کانال جانبی (مانند ارسال ایمیل به آنها برای اطلاعرسانی که از قبل حساب دارند) است.
- اطمینان از اینکه زمان ارسال پاسخ همیشه یکسان باشد، صرف نظر از اینکه کاربر وجود داشته باشد یا نه. در Go، این به طور کلی به معنای واگذاری کار به یک goroutine پسزمینه است.
متأسفانه، این تخفیفها معمولاً پیچیدگی برنامه شما را افزایش میدهند و اصطکاک و ابهام را به فرآیندهای شما اضافه میکنند. برای تمام کاربران عادی شما که مهاجم نیستند، این از نظر UX یک نقطه منفی است. باید بپرسید: آیا ارزش این مبادله را دارد؟
چند نکته برای فکر کردن هنگام پاسخ به این سؤال وجود دارد. حریم خصوصی کاربران در برنامه شما چقدر مهم است؟ یک حساب به خطر افتاده برای مهاجم چقدر جذاب (ارزشمند) است؟ کاهش اصطکاک در فرآیندهای کاربری شما چقدر مهم است؟ پاسخهای این سؤالات از پروژهای به پروژه دیگر متفاوت خواهند بود و به شکلگیری تصمیم شما کمک خواهند کرد.
ارزش اشاره کردن دارد که بسیاری از سرویسهای بزرگ، از جمله Twitter، GitHub و Amazon، از شمارش کاربران جلوگیری نمیکنند (حداقل در صفحات ثبتنام خود). من پیشنهاد نمیکنم که این موضوع قابل قبول است — فقط آن شرکتها تصمیم گرفتهاند که اصطکاک اضافی برای کاربران بدتر از خطرات حریم خصوصی و امنیتی در مورد خاص آنها است.