Alex Edwards Let's Go часть 5

edwardsLetsGo учебный материал по написанию приложения на Go с авторизацией пользователя. Краткий конспект с основными мыслями (глава 17)

Добавьте страницу «О программе» в приложение

Создайте обработчик about

File: cmd/web/handlers.go

package main

...

func (app *application) about(w http.ResponseWriter, r *http.Request) {
	data := app.newTemplateData(r)
	data.About = "Это приложение создано для того,чтобы тренироваться. <br />И я думаю, здесь потом написать markdown текст."

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

Создайте маршрут GET /about

File: cmd/web/routes.go

router.Handler(http.MethodGet, "/about", dynamic.ThenFunc(app.about))

Создайте шаблон about .tmpl

File: ui/html/pages/about.tmpl

{{define "title"}}О приложении{{end}}

{{define "main"}}
{{with .About}}
    <div class='snippet'>
		<p>{{.}}</p>
	</div>
{{end}}
{{end}}

Создать навигацию в nav.tmpl

<a href='/about'>About</a>

Создать новый флаг -debug

Создаем описание флага

Три точки правки для добавления флага:

type application struct {
    debug          bool // 1. Добавить в структуру приложения
    errorLog       *log.Logger
    infoLog        *log.Logger
    snippets       models.SnippetModelInterface
    users          models.UserModelInterface
    templateCache  map[string]*template.Template
    formDecoder    *form.Decoder
    sessionManager *scs.SessionManager
}
...
	debug := flag.Bool("debug", false, "Включить режим отладки, по умолчанию false") //2. добавить описание флага до Parse
...
app := &application{
	debug:          *debug, // Инициализация флага в экземпляре приложения
	errorLog:       errorLog,
	infoLog:        infoLog,
	snippets:       &models.SnippetModel{DB: db},
	users:          &models.UserModel{DB: db},
	templateCache:  templateCache,
	formDecoder:    formDecoder,
	sessionManager: sessionManager,
}

Настройка cmd/web/helpers.go для serverError()

Добавим условие для проверки флага app.debug.

func (app *application) serverError(w http.ResponseWriter, err error) {
    trace := fmt.Sprintf("%s\n%s", err.Error(), debug.Stack())
    app.errorLog.Output(2, trace)

    if app.debug { //Добавил условие проверки флага
        http.Error(w, trace, http.StatusInternalServerError)
        return
    }

    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}

Продолжаем тестирование. Тестирование обработчика snippetCreate

Создадим сквозной тест для маршрута GET /snippet/create.

  • Пользователи, не прошедшие аутентификацию, перенаправляются на форму входа.
  • Пользователям, прошедшим проверку подлинности, отображается форма для создания нового сниппета.

Создадим заготовку для теста handlers_test.go

func TestSnippetCreate(t *testing.T) {
	// Создаем новый экземпляр нашей структуры приложения, который использует мокированные зависимости.
	app := newTestApplication(t)

	// Устанавливаем новый тестовый сервер для выполнения сквозных тестов.
	ts := newTestServer(t, app.routes())
	defer ts.Close()

}

Создадим подтест с проверкой доступа неаутентифицированного пользователя

func TestSnippetCreate(t *testing.T) {
	// Создаем новый экземпляр нашей структуры приложения, который использует мокированные зависимости.
	app := newTestApplication(t)

	// Устанавливаем новый тестовый сервер для выполнения сквозных тестов.
	ts := newTestServer(t, app.routes())
	defer ts.Close()

	t.Run("Unauthenticated", func(t *testing.T) {//название теста и безымянная функция
        code, headers, _ := ts.get(t, "/snippet/create") //запрос к серверу с моками по адресу проверки
        //code и headers - запишем ответы от сервера кода и заголовка
        assert.Equal(t, code,  http.StatusSeeOther) //проверим код
        assert.Equal(t, headers.Get("Location"), "/user/login") //проверим заголовок Location
		//location показывает адрес текущей страницы
    })
}

Страница личного кабинета

Добавить новую страницу «Ваша учетная запись» в приложение. Он должен быть сопоставлен с новым маршрутом GET /account/view и отображать имя, адрес электронной почты и дату регистрации для текущего аутентифицированного пользователя

Создадим в файле internal/models/users.go функция UserModel.Get()

  1. Добавим метод Get в интерфейс UserModelInterface
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) //добавил метод Get
}
  1. Создадим метод Get
func (m *UserModel) Get(id int) (*User, error) {
	var user User
	stmt := `SELECT id, name, email, created FROM users WHERE id = ?`

	err := m.DB.QueryRow(stmt, id).Scan(&user.ID, &user.Name, &user.Email, &user.Created)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrNoRecord
		} else {
			return nil, err
		}
	}

	return &user, err
}

routes.go

router.Handler(http.MethodGet, "/account/view", protected.ThenFunc(app.accountView))

handlers.go

Добавим функцию accountView

func (app *application) accountView(w http.ResponseWriter, r *http.Request) {
    userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")

    user, err := app.users.Get(userID)
    if err != nil {
        if errors.Is(err, models.ErrNoRecord) {
            http.Redirect(w, r, "/user/login", http.StatusSeeOther)
        } else {
            app.serverError(w, err)
        }
        return
    }

    fmt.Fprintf(w, "%+v", user)
}

templates.go

добавим поле User

type templateData struct {
    CurrentYear     int
    Snippet         *models.Snippet
    Snippets        []*models.Snippet
    Form            any
    Flash           string
    IsAuthenticated bool
    CSRFToken       string
    User            *models.User //добавили User
}

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>
    </table>
    {{end }}
{{end}}

handkers.go

func (app *application) accountView(w http.ResponseWriter, r *http.Request) {
    userID := app.sessionManager.GetInt(r.Context(), "authenticatedUserID")

    user, err := app.users.Get(userID)
    if err != nil {
        if errors.Is(err, models.ErrNoRecord) {
            http.Redirect(w, r, "/user/login", http.StatusSeeOther)
        } else {
            app.serverError(w, err)
        }
        return
    }

    data := app.newTemplateData(r)
    data.User = user

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

Настроим меню приложения

{{define "nav"}}
<nav>
    <div>
        <a href='/'>Home</a>
        <a href='/about'>About</a>
         {{if .IsAuthenticated}}
            <a href='/snippet/create'>Create snippet</a>
        {{end}}
    </div>
    <div>
        {{if .IsAuthenticated}}
            <!-- Add the view account link for authenticated users -->
            <a href='/account/view'>Account</a>
            <form action='/user/logout' method='POST'>
                <input type='hidden' name='csrf_token' value='{{.CSRFToken}}'>
                <button>Logout</button>
            </form>
        {{else}}
            <a href='/user/signup'>Signup</a>
            <a href='/user/login'>Login</a>
        {{end}}
    </div>
</nav>
{{end}}

Перенаправление пользователя после входа в систему

Обновить middleware requireAuthentication()

чтобы перед тем, как неаутентифицированный пользователь будет перенаправлен на страницу входа, URL-путь, который он пытается посетить, был добавлен в данные его сеанса.

func (app *application) requireAuthentication(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !app.isAuthenticated(r) {
            // Add the path that the user is trying to access to their session
            // data.
            app.sessionManager.Put(r.Context(), "redirectPathAfterLogin", r.URL.Path)
            http.Redirect(w, r, "/user/login", http.StatusSeeOther)
            return
        }

        w.Header().Add("Cache-Control", "no-store")

        next.ServeHTTP(w, r)
    })
}

Обновите обработчик userLogin

чтобы проверить путь URL-адреса пользователя в сеансе после успешного входа в систему. Если он существует, удалите его из данных сеанса и перенаправьте пользователя на этот URL-путь. В противном случае по умолчанию пользователь будет перенаправлен в /snippet/create.

    path := app.sessionManager.PopString(r.Context(), "redirectPathAfterLogin")
    if path != "" {
        http.Redirect(w, r, path, http.StatusSeeOther)
        return
    }

    http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)

Восстановление пароля

Создадим 2 новых routes и handlers

GET /account/password/update — accountPasswordUpdate

POST /account/password/update — accountPasswordUpdatePost

handlers.go

func (app *application) accountPasswordUpdate(w http.ResponseWriter, r *http.Request) {
	data := app.newTemplateData(r)
	data.Form = userPasswordUpdate{}
	app.render(w, http.StatusOK, "password.tmpl", data)
}

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

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

routes.go

    router.Handler(http.MethodGet, "/account/password/update", protected.ThenFunc(app.accountPasswordUpdate))
    router.Handler(http.MethodPost, "/account/password/update", protected.ThenFunc(app.accountPasswordUpdatePost))

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}}

handlers.go

Обработчик Get

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, http.StatusOK, "password.tmpl", data)
}

обработчик Post

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, http.StatusUnprocessableEntity, "password.tmpl", data)
        return
    }
}

users.go

  1. добавим интерфейс PasswordUpdate
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
}
  1. добавим метод PasswordUpdate
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
}

handlers.go

окончательный вариантфункции accountPasswordUpdatePost

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, 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, http.StatusUnprocessableEntity, "password.tmpl", data)
        } else if err != nil {
            app.serverError(w, err)
        }
        return
    }

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

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

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}}