let's go를 배워보자 11일차 - User Authentication 1

0

lets-go

목록 보기
11/15

User Authentication

이제 user 인증 기능을 우리의 application에 추가해보도록 하자. 계정을 등록하고, 오직 인증된 유저만 새로운 snippet을 만들도록 하는 것이다. 로그인 되지 않은 user는 snippet을 볼 수만 있도록 하는 것이다.

우리의 application은 다음의 process로 동작하게 된다.

  1. user는 /user/signup에 있는 form을 작성함으로서 이름과 이메일, 주소, 비밀번호를 입력한다. 우리는 이 정보를 user database table에 저장한다.
  2. user는 /user/login에 접속하여 login을 할 수 있는 페이지가 나오고 email과 password를 사용하여 인증할 수 있다.
  3. 전송된 email과 password를 우리의 database와 비교하여 user가 맞는 지 확인한다. 만약 맞다면 session data에 user와 관련된 id값을 넣어준다. 이 id값은 authenticatedUserId key값을 가진다.
  4. 이후 요청이 오면 session data에서 authenticatedUserId을 확인한다. 만약 존재한다면 user는 성공적으로 로그인 된 것이다. 우리는 session이 만료되기 전까지 이를 계속해서 확인한다. 만약 authenticatedUserId에 어떠한 값도 없으면 우리는 user가 logout되었다고 판단한다.

1. Routes Setup

우리의 application에 5개의 routes를 추가해보도록 하자. 이는 다음과 같다.

MethodPatternHandlerAction
GET/homeDisplay the home page
GET/snippet/:idshowSnippetDisplay a specific snippets
GET/snippet/createcreateSnippetFormDisplay the new snippet form
POST/snippet/createcreateSnippetCreate a new snippet
GET/user/signupsignupUserFormDisplay the user signup form
POST/user/signupsignupUserCreate a new user
GET/user/loginloginUserFormDisplay the user login form
POST/user/loginloginUserAuthenticate and login the user
POST/user/logoutlogoutUserLogout the user
GET/static/http.FileServerServe a specific static file

signupUser, loginUser 그리고 logoutUserPOST를 쓴다는 점을 주목하도록 하자.

handlers.go 파일을 열고 앞서 말했던 5개의 핸들러를 추가하도록 하자.

  • cmd/web/handlers.go
...
func (app *application) signupUserForm(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Display the user signup form...")
}

func (app *application) signupUser(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Create a new user...")
}

func (app *application) loginUserForm(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Display the user login form...")
}

func (app *application) loginUser(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Authenticate and login the user")
}

func (app *application) logoutUser(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "Logout the user")
}

이제 상응하는 핸들러를 routes.go 파일에 추가하면 된다.

  • cmd/web/routes.go
...
func (app *application) routes() http.Handler {
	standardMiddleware := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
	dynamicMiddleware := alice.New(app.session.Enable)

	mux := pat.New()

	mux.Get("/", dynamicMiddleware.ThenFunc(app.home))
	mux.Get("/snippet/create", dynamicMiddleware.ThenFunc(app.createSnippetForm))
	mux.Post("/snippet/create", dynamicMiddleware.ThenFunc(app.createSnippet))
	mux.Get("/snippet/:id", dynamicMiddleware.ThenFunc(app.showSnippet))
	// Add the five new routes.
	mux.Get("/user/signup", dynamicMiddleware.ThenFunc(app.signupUserForm))
	mux.Post("/user/signup", dynamicMiddleware.ThenFunc(app.signupUser))
	mux.Get("/user/login", dynamicMiddleware.ThenFunc(app.loginUserForm))
	mux.Post("/user/login", dynamicMiddleware.ThenFunc(app.loginUser))
	mux.Post("/user/logout", dynamicMiddleware.ThenFunc(app.logoutUser))

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Get("/static/", http.StripPrefix("/static", fileServer))
	return standardMiddleware.Then(mux)
}

이제 해당 routes로 접속할 수 있는 navigation item을 만들 수 있도록 base.layout.tmpl 파일을 수정해보도록 하자.

  • ui/html/base.layout.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
    <head>
        <meta charset='utf-8'>
        <title>{{template "title" .}} - Snippetbox</title>
        <link rel='stylesheet' href='/static/css/main.css'>
        <link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
        <link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
    </head>
    <body>
        <header>
            <h1><a href='/'>Snippetbox</a></h1>
        </header>
        <nav>
        <!-- Update the navigation to include signup, login and logout links -->
            <div>
                <a href='/'>Home</a>
                <a href='/snippet/create'>Create snippet</a>
            </div>
            <div>
                <a href='/user/signup'>Signup</a>
                <a href='/user/login'>Login</a>
                <form action='/user/logout' method='POST'>
                    <button>Logout</button>
                </form>
            </div>
        </nav>
        <main>
            {{with .Flash}}
            <div class='flash'>{{.}}</div>
            {{end}}
            {{template "main" .}}
        </main>
        {{template "footer" .}}
        <script src="/static/js/main.js" type="text/javascript"></script>
    </body>
</html>
{{end}}

navSignup, Login, Logout을 추가하였다. 이제 잘 동작하는 지를 확인해보자.

만들었던 버튼들이 잘나오면 성공이다.

2. Creating a Users Model

route도 세팅하였으니 남은 건 user database table을 만들고 database model을 추가하는 것이다.

terminal을 열고 mysql을 시작한 다음 root 유저로 다음의 sql statement를 집어넣어 user table을 만들도록 하자.

sudo mysql -u root

USE snippetbox;

CREATE TABLE users (
    id INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    hashed_password CHAR(60) NOT NULL,
    created DATETIME NOT NULL,
    active BOOLEAN NOT NULL DEFAULT TRUE
);
ALTER TABLE users ADD CONSTRAINT users_uc_email UNIQUE (email);
  • id필드는 table의 PK로 자동으로 증가하는 필드이다. 이는 user id 값이 unique한 양의 정수 값이라는 것을 보장한다.
  • hashed_password필드는 CHAR(60)으로 설정하였다. 이는 user password의 hash값을 db에 저장하기 위한 것이다. 즉, password 그 자체를 저장하지 않겠다는 것이다.
  • email 필드에 UNIQUE column을 추가하였고, 이 제약을 users_uc_email이라고 지었다. 이 제약사항은 같은 email을 가지고 있는 두 개의 유저가 없다는 것을 의미한다. 만약 같은 email을 가지는 값이 db에 있었다면 mysql 자체에서 ERROR를 반환한다.
  • active column은 user 계정의 상태를 포함하기위해 사용하는 것이다. TRUE일 때는 user가 login 상태이고, user는 application을 정상적으로 사용할 수 있다. 만약 FALSE라면 user은 log in되어 있지 않다는 것이다.

3. Building the Model in Go

새로운 user table과 go application이 손쉽게 동작하기위해 model을 하나 만들자.

pkg/models/model.go 파일을 열고 각 user의 정보를 갖는 User 구조체를 만들고, 추가적으로 여러 error 타입들을 만들도록 하자.

  • pkg/model/models.go
package models

import (
	"errors"
	"time"
)

var (
	ErrNoRecord = errors.New("models: no matching record found")
	// Add a new ErrInvalidCredentials error. We'll use this later if a user
	// tries to login with an incorrect email address or password.
	ErrInvalidCredentials = errors.New("models: invalid credentials")
	// Add a new ErrDuplicateEmail error. We'll use this later if a user
	// tries to signup with an email address that's already in use.
	ErrDuplicateEmail = errors.New("models: duplicate email")
)

type Snippet struct {
	ID      int
	Title   string
	Content string
	Created time.Time
	Expires time.Time
}

// Define a new User type. Notice how the field names and types align
// with the columns in the database `users` table?
type User struct {
	ID             int
	Name           string
	Email          string
	HashedPassword []byte
	Created        time.Time
	Active         bool
}

이제 User 타입이 만들어졌으니 실제 database model을 만들도록 하자. pkg/models/mysql/users.go 파일을 만들어 database에 접근하여 user정보를 얻도록 하자.

touch pkg/models/mysql/users.go

만들고 나서, 새로운 UserModel 타입을 새로운 함수들과 함께 넣어주도록 하자.

  • pkg/models/mysql/users.go
package mysql

import (
	"database/sql"

	"github.com/gyu-young-park/snippetbox/pkg/models"
)

type UserModel struct {
	DB *sql.DB
}

// We'll use the Insert method to add a new record to the users table.
func (m *UserModel) Insert(name, email, password string) error {
	return nil
}

// We'll use the Authenticate method to verify whether a user exists with
// the provided email address and password. This will return the relevant
// user ID if they do.
func (m *UserModel) Authenticate(email, password string) (int, error) {
	return 0, nil
}

// We'll use the Get method to fetch details for a specific user based
// on their user ID.
func (m *UserModel) Get(id int) (*models.User, error) {
	return nil, nil
}

마지막 작업은 application 구조체에 새로운 필드를 만들어 새로운 구조체를 넣어주는 것이다. 이를 통해 handlers에서 db에 접근할 수 있도록 하고, main에서 만든 db 인스턴스를 넘겨주기 위한 것이다.

main.go를 다음과 같이 변경하도록 하자.

  • cmd/web/main.go
// Add a new users field to the application struct.
type application struct {
	errorLog      *log.Logger
	infoLog       *log.Logger
	session       *sessions.Session
	snippets      *mysql.SnippetModel
	templateCache map[string]*template.Template
	users         *mysql.UserModel
}

func main() {
    ...
	session := sessions.New([]byte(*secret))
	session.Lifetime = 12 * time.Hour
	session.Secure = true
	// Initialize a mysql.UserModel instance and add it to the application
	// dependencies.
	app := &application{
		errorLog:      errorLog,
		infoLog:       infoLog,
		session:       session,
		snippets:      &mysql.SnippetModel{DB: db},
		templateCache: templateCache,
		users:         &mysql.UserModel{DB: db},
	}

	tlsConfig := &tls.Config{
		CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
	}

	srv := &http.Server{
		Addr:      *addr,
		ErrorLog:  errorLog,
		Handler:   app.routes(),
		TLSConfig: tlsConfig,
		// Add Idle, Read and Write timeouts to the server.
		IdleTimeout:  time.Minute,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	infoLog.Printf("Starting server on %s", *addr)
	err = srv.ListenAndServeTLS("./tls//cert.pem", "./tls/key.pem")
	errorLog.Fatal(err)
}

이제 application을 실행해보면 아무 문제없이 이전과 동일하게 동작하는 것을 확인할 수 있다.

4. User Signup and Password Encryption

로그인 이전에 먼저 회원가입 기능인 sign up이 필요하다.

ui/html/signup.page.tmpl 파일을 만든다음 다음의 markup 파일을 추가하도록 하자.

touch ui/html/signup.page.tmpl
  • ui/html/signup.page.tmpl
{{template "base" .}}

{{define "title"}}Signup{{end}}

{{define "main"}}
<form action='/user/signup' method='POST' novalidate>
    {{with .Form}}
        <div>
            <label>Name:</label>
            {{with .Errors.Get "name"}}
                <label class='error'>{{.}}</label>
            {{end}}
            <input type='text' name='name' value='{{.Get "name"}}'>
        </div>
        <div>
            <label>Email:</label>
            {{with .Errors.Get "email"}}
                <label class='error'>{{.}}</label>
            {{end}}
            <input type='email' name='email' value='{{.Get "email"}}'>
        </div>
        <div>
            <label>Password:</label>
            {{with .Errors.Get "password"}}
                <label class='error'>{{.}}</label>
            {{end}}
            <input type='password' name='password'>
        </div>
        <div>
            <input type='submit' value='Signup'>
        </div>
    {{end}}
</form>
{{end}}

signupUserForm handler를 다음과 같이 만들어주도록 하자.

  • cmd/web/handlers.go
...
func (app *application) signupUserForm(w http.ResponseWriter, r *http.Request) {
	app.render(w, r, "signup.page.tmpl", &templateData{
		Form: forms.New(nil),
	})
}
...

다음과 같이 만들고 서버를 실행해보자.

localhost:4000/user/signup에 접속하였을 때 페이지가 제대로 나왔으면 성공한 것이다.

5. Validating the User Input

signup 정보가 서버로 전송되면 서버가 가장 먼저해야할 일은 제대로 된 데이터가 온지를 validating하는 것이다.

  1. user의 이름과 이메일 주소, password가 blank가 아닌 지 검사하도록 한다.
  2. email 주소의 무결성을 검사한다.
  3. passwrod가 최소한 10글자 이상되는 지를 검사한다.
  4. email 주소가 이미 이전에 있었는 지 검사한다.

우리는 1~3까지의 검사를 pkg/forms/form.go 파일에서 두 가지 새로운 helper함수를 만들어 검사할 수 있다. 최소 문자 개수인 MinLength()와 정규표현식으로 email 주소의 무결성을 검사하는 MatchesPattern()를 만들도록 하자.

  • pkg/forms/form.go
package forms

import (
	"fmt"
	"net/url"
	"regexp"
	"strings"
	"unicode/utf8"
)

// Use the regexp.MustCompile() function to parse a pattern and compile a
// regular expression for sanity checking the format of an email address.
// This returns a *regexp.Regexp object, or panics in the event of an error.
// Doing this once at runtime, and storing the compiled regular expression
// object in a variable, is more performant than re-compiling the pattern with
// every request.
var EmailRX = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](" +
	"?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")

type Form struct {
	url.Values
	Errors errors
}

...

// Implement a MinLength method to check that a specific field in the form
// contains a minimum number of characters. If the check fails then add the
// appropriate message to the form errors.
func (f *Form) MinLength(field string, d int) {
	value := f.Get(field)
	if value == "" {
		return
	}
	if utf8.RuneCountInString(value) < d {
		f.Errors.Add(field, fmt.Sprintf("This field is too short (minimum is %d characters)", d))
	}
}

// Implement a MatchesPattern method to check that a specific field in the form
// matches a regular expression. If the check fails then add the
// appropriate message to the form errors.
func (f *Form) MatchesPattern(field string, pattern *regexp.Regexp) {
	value := f.Get(field)
	if value == "" {
		return
	}

	if !pattern.MatchString(value) {
		f.Errors.Add(field, "This field is invalid")
	}
}

func (f *Form) Valid() bool {
	return len(f.Errors) == 0
}

MinLength은 field의 글자 수가 d보다 작다면 에러가 발생했다고 표시한다. MatchesPattern은 field에 EmailRX 패턴에 포함된 내용이 있는 지 확인하도록 한다.

EmailRX 정규 표현식 패턴에 대해서 설명하자면 다음과 같다.

  1. 해당 패턴은 W3C와 Web Hypertext Application Technology Working Group에서 추천된 내용이다.
  2. EmailRX 패턴은 string literal로 해석되기 때문에 double-escape special characters로 regexp에 표시해야한다. 가령 \\로 써야 \로 해석한다.

handlers.go 파일로 가서 form을 처리하기 위한 일부 코드를 추가하고 validation checks를 추가하도록 하자.

  • cmd/web/handles.go
...
func (app *application) signupUser(w http.ResponseWriter, r *http.Request) {
	// Parse the form data
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	form := forms.New(r.PostForm)
	form.Required("name", "email", "password")
	form.MaxLength("name", 255)
	form.MaxLength("email", 255)
	form.MatchesPattern("email", forms.EmailRX)
	form.MinLength("password", 10)

	if !form.Valid() {
		app.render(w, r, "signup.page.tmpl", &templateData{Form: form})
		return
	}

	fmt.Fprintln(w, "Create a new user...")
}
...

server를 구동하고 signup form에 데이터를 적어주도록 하자.

이제 남은 validation check은 email이 이전에 이미 사용했는가? 를 확인하는 것이다.

우리는 database에서 UNIQUE 제약사항을 users 테이블의 email 필드에 넣었기 때문에 email이 unique함을 보장하고 있다. 때문에 비지니스 로직적으로나 데이터 integrity 관점으로나 이미 완성되었기 때문에 큰 문제는 없다. 단, 한 가지 문제가 있는 데, email이 이미 있다는 것을 user에게 어떻게 알리냐는 것이다. 이에 대해서는 다음 CHAPTER에서 다루도록 하자.

6. A Bried Introduction to Bcrypt

database가 해킹당해도 password와 같은 민감한 정보는 알려지면 안된다. 이를 위해 password를 단방향성 hash알고리즘으로 암호화하는 것은 좋은 아이디어이다.

먼저 plain-text를 hashing하는 함수는 bcrypt.GenerateFromPassword() 함수이다.

hash, err := bcrypt.GenerateFromPassword([]byte("my plain text password"), 12)

두 번째 파라미터로 넘어가는 값은 cost로 4~31사이의 integer값이다. cost 12라는 의미는 2^12=4096 bcrypt iteration들이 hashing된 password를 만드는 데 사용된다는 것이다. 이보다 적게 사용하는 것을 추천하진 않는다.

또한, bcrypt.GenerateFromPassword 함수는 random salt를 password에 추가하여 rainbow-table attack들을 막아준다.

만약, 해당 함수를 사용하여 $2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6HzGJSWG와 같은 해싱 함수를 만들었다고 하자. 해당 해싱 값을 databas에 저장하고, 앞으로 입력으로 들어온 plain text와 비교하여 로그인이 성공하였는 지, 실패하였는 지를 알고싶다.

해싱 값과 plain text의 비교를 위해 bcrypt.CompareHashAndPassword()를 사용하도록 하자.

hash := []byte("$2a$12$NuTjWXm3KKntReFwyBVHyuf/to.HEwTy.eS206TNfkGfr6GzGJSWG")
err := bcrypt.CompareHashAndPassword(hash, []byte("my plain text password"))

bcrypt.CompareHashAndPassword() 함수가 만약 nil을 반환하면 text plain password와 매칭되었다. 즉, 동일하다는 것이고, 이들이 다르다면 error가 반환된다.

7. Storing the User Details

다음으로 우리가 해야할 것은 users 테이블에 새로운 user를 추가하는 것이다.

두 가지 재미난 사실이 있는데, 첫 번째는 우리는 해싱된 password를 저장할 것이라는 것이고, 두 번째는 table의 UNIQUE 제약사항을 위반하여 발생하는 email 중복 에러를 관리할 것이다.

MySQL에서 반환되는 모든 에러들은 특정한 코드값을 가진다. 이를 사용하여 발생한 error의 문제가 무엇인지를 확인할 수 있다. 중복 이메일 오류에 관해서는 1062 (ER_DUP_ENTRY)가 사용된다.

pkg/models/mysql/users.go 파일을 열고 다음의 코드를 추가하도록 하자.

  • pkg/models/mysql/users.go
...
func (m *UserModel) Insert(name, email, password string) error {
	// Create a bcrypt hash of the plain-text password
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 12)
	if err != nil {
		return err
	}
	stmt := `INSERT INTO users (name, email, hashed_password, created) VALUES(?, ?, ?, UTC_TIMESTAMP())`
	// Use the Exec() method to insert the user details and hashed password
	// into the users table.
	_, err = m.DB.Exec(stmt, name, email, string(hashedPassword))
	if err != nil {
		// If this returns an error, we use the errors.As() function to check
		// whether the error has the type *mysql.MySQLError. If it does, the
		// error will be assigned to the mySQLError variable. We can then check
		// whether or not the error relates to our users_uc_email key by
		// checking the contents of the message string. If it does, we return
		// an ErrDuplicateEmail error.
		var mySQLError *mysql.MySQLError
		if errors.As(err, &mySQLError) {
			if mySQLError.Number == 1062 && strings.Contains(mySQLError.Message, "users_uc_email") {
				return models.ErrDuplicateEmail
			}
		}
		return err
	}
	return nil
}
...

Insert 메서드로 database에 정보를 저장하고, duplicated되었다면 errors.As로 확인하도록 하였다.

errors.Is는 특정 에러 메시지에 대해서 해당 error type이 그 메시지를 갖고 있는 가를 확인하는 반면, errors.As는 특정 에러 타입을 갖고 있는 지에 대해서 확인할 수 있다.

즉, 해당 err메시지를 뒤져 특정 err 타입인지를 확인하고 맵핑시켜준다. 일일히 type assertion을 쓰는 방법보다 더 간편하다.

우리는 errors.As를 통해서 에러 상태코드와 메시지를 확인할 수 있게 되는 것이다.

이제 signupUser 핸들러를 다음과 같이 업데이트하도록 하자.

  • cmd/web/handles.go
...
func (app *application) signupUser(w http.ResponseWriter, r *http.Request) {
	// Parse the form data
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}

	form := forms.New(r.PostForm)
	form.Required("name", "email", "password")
	form.MaxLength("name", 255)
	form.MaxLength("email", 255)
	form.MatchesPattern("email", forms.EmailRX)
	form.MinLength("password", 10)

	if !form.Valid() {
		app.render(w, r, "signup.page.tmpl", &templateData{Form: form})
		return
	}
	// Try to create a new user record in the database. If the email already exists
	// add an error message to the form and re-display it.
	err = app.users.Insert(form.Get("name"), form.Get("email"), form.Get("password"))
	if err != nil {
		if errors.Is(err, models.ErrDuplicateEmail) {
			form.Errors.Add("email", "Address is already in use")
			app.render(w, r, "signup.page.tmpl", &templateData{Form: form})
		} else {
			app.serverError(w, err)
		}
		return
	}
	// Otherwise add a confirmation flash message to the session confirming that
	// their signup worked and asking them to log in.
	app.session.Put(r, "flash", "Your signup was successful. Please log in")
	// And redirect the user to the login page.
	http.Redirect(w, r, "/user/login", http.StatusSeeOther)
}
...

다음의 코드를 실행하고 https://localhost:4000/user/signup으로 가서 회원가입을 하도록 하자.

Display the user login form... 화면이 나왔다면 성공한 것이다.

이제 database에서 성공적으로 데이터가 추가되었는 지를 확인해보도록 하자.

mysql;
use snippetbox;
select * from users;

이 때 데이터가 추가되었다면 성공한 것이다.

추가적으로 signup page로 돌아가서 한 번 동일한 email로 가입을 해보도록 하자. Address is already in use라는 에러가 나온다면 성공이다.

8. User Login

이제 user login 페이지를 만들어보도록 하자. ui/html/login.page.tmpl에 템플릿을 추가하도록 하자.

touch ui/html/login.page.tmpl
  • ui/html/login.page.tmpl
{{template "base" .}}
{{define "title"}}Login{{end}}
{{define "main"}}
<form action='/user/login' method='POST' novalidate>
    {{with .Form}}
        {{with .Errors.Get "generic"}}
            <div class='error'>{{.}}</div>
        {{end}}
        <div>
            <label>Email:</label>
            <input type='email' name='email' value='{{.Get "email"}}'>
        </div>
        <div>
            <label>Password:</label>
            <input type='password' name='password'>
        </div>
        <div>
            <input type='submit' value='Login'>
        </div>
    {{end}}
</form>
{{end}}

위 코드에서 우리가 추가한 {{with .Errors.Get "generic"}} action 부분을 보도록 하자. 각 필드에 대한 error message를 보여주기 보다는 이메일과 password가 잘못되었다는 메시지를 동시에 줄 것이다. 가령 Your email address or password is wrong과 같이 메시지를 전달하도록 할 것이다.

이는 보안상의 문제로 login할 때 해커가 자신이 틀린 것이 email인지 password인지를 알 수 있기 때문이다.

이제 해당 page를 loginUserForm을 통해서 렌더링해보도록 하자.

  • cmd/web/handlers.go
func (app *application) loginUserForm(w http.ResponseWriter, r *http.Request) {
	app.render(w, r, "login.page.tmpl", &templateData{
		Form: forms.New(nil),
	})
}

application을 구동하고 https://localhost:4000/user/login에 접속해보도록 하자.

login page가 나왔다면 성공한 것이다.

9. Verifying the User Details

다음은 user에 의해 제출된 email과 password를 검증하여 이것이 정확한 가를 확인하는 것이다.

검증 로직의 가장 큰 핵심 기능으로 UserModel.Authenticate()을 user model에 만들어주도록 하자. 여기에는 두 가지 필요한 점이 있다.

  1. 첫번째로 우리의 MySQL users table로 부터 email 주소와 연관된 hashed password를 가져와야한다. 만약 email이 database에서 없다면 또는 user가 deactivated되어 있다면 우리는 이전에 만든 ErrInvalidCredentials를 반환할 것이다.

  2. 만약 이를 통과하면 user가 log in할 때 제공한 plain test password와 users table의 해싱된 password를 비교하도록 한다. 만약 이들이 맞지 않다면 우리는 ErrInvalidCredentials 에러를 반환할 것이다. 그러나 만약 이들이 맞다면 우리는 database에서 user의 id를 반환할 것이다.

위 내용을 구현하기위해 pkg/models/mysql/users.go 파일에 다음의 코드를 넣어주도록 하자.

  • pkg/models/mysql/users.go
...
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, or the user is not active, we return the
	// ErrInvalidCredentials error.
	var id int
	var hashedPassword []byte
	stmt := "SELECT id, hashed_password FROM users WHERE email = ? AND active = TRUE"
	row := m.DB.QueryRow(stmt, email)
	err := row.Scan(&id, &hashedPassword)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return 0, models.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, models.ErrInvalidCredentials
		} else {
			return 0, err
		}
	}
	// Otherwise, the password is correct. Return the user ID.
	return id, nil
}
...

다음으로는 loginUser handler에서 제출된 login form data를 파싱하고 UserModel.Authenticate() 메서드를 호출하도록 하자.

만약 login 정보가 valid하다면 우리는 userid정보를 session에 넣고, 해당 정보가 성공적으로 인증되었다고 판단할 것이다.

  • cmd/web/handlers.go
...
func (app *application) loginUser(w http.ResponseWriter, r *http.Request) {
	err := r.ParseForm()
	if err != nil {
		app.clientError(w, http.StatusBadRequest)
		return
	}
	// Check whether the credentials are valid. If they're not, add a generic error
	// message to the form failures map and re-display the login page.
	form := forms.New(r.PostForm)
	id, err := app.users.Authenticate(form.Get("email"), form.Get("password"))
	if err != nil {
		if errors.Is(err, models.ErrInvalidCredentials) {
			form.Errors.Add("generic", "Email or Password is incorrect")
			app.render(w, r, "login.page.tmpl", &templateData{Form: form})
		} else {
			app.serverError(w, err)
		}
		return
	}
	// Add the ID of the current user to the session, so that they are now 'logged
	// in'.
	app.session.Put(r, "authenticatedUserID", id)
	// Redirect the user to the create snippet page.
	http.Redirect(w, r, "/snippet/create", http.StatusSeeOther)
}
...

이제 app 서버를 켜고 login을 해보도록 하자.

잘못된 정보를 입력했을 때 Email or Password is incorrect라는 정보가 나온다면 성공한 것이다.

10. User Logout

user logout은 login과 signup의 반대이다. 우리가 해야할 것은 authenticatedUserID를 session에서 제거하는 것 뿐이다. cmd/web/handlers.go에서 authenticatedUserID session을 제거하도록 하자.

  • cmd/web/handlers.go
func (app *application) logoutUser(w http.ResponseWriter, r *http.Request) {
	// Remove the authenticatedUserID from the session data so that the user is
	// 'logged out'.
	app.session.Remove(r, "authenticatedUserID")
	// Add a flash message to the session to confirm to the user that they've been
	// logged out.
	app.session.Put(r, "flash", "You've been logged out successfully!")
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

session을 제거하면 login하였다는 정보가 사라진다. 이후 homepage의 main page로 이동시키기 위해 redirect시키도록 하자.

11. User Authorization

문제는 우리는 authenticatedUserID session data로 어떠한 인증을 하지 않았다는 것이다. 즉, Create snippet 버튼을 누르는 것은 로그인이 된 유저만 가능하도록 한다던지 login이 되었다는 검증을 하지 않고 프로그램을 구동하고 있었다.

authorization된 유저들은 다음의 작업을 할 수 있다.

  1. 인증된 유저만이 새로운 snippet을 만들 수 있다.
  2. navigation bar의 내용이 user이 인증되었는 가에 따라 다르게 보이도록 한다. 인증된 유저들은 Home, Create snippet, Logout을 보이도록 한다. 인증되지 않은 유저들은 Home, Signup, Login을 보이도록 한다.

우리는 authenticatedUserID을 통해서 이를 수행할 수 있다.

cmd/web/helpers.go 파일을 열어서 isAuthenticated() helper function을 추가하여 인증 status를 반환하도록 하자.

  • cmd/web/helpers.go
...
// Return true if the current request is from authenticated user, otherwise return false.
func (app *application) isAuthenticated(r *http.Request) bool {
	return app.session.Exists(r, "authenticatedUserID")
}
...

우리는 간단하게 isAuthenticated을 호출하여 인증되었는지 안되었는 지를 확인할 수 있다.

이제 우리는 해당 정보를 html template에 넘겨서 navigation bar를 적절히 토글할 수 있도록 하자.

두 가지 part로 나뉘는 데, 첫번째는 IsAuthenticated 필드를 우리의 templateData 구조체에 넣도록 하자.

  • cmd/web/templates.go
...
type templateData struct {
	CurrentYear     int
	Flash           string
	Form            *forms.Form
	IsAuthenticated bool
	Snippet         *models.Snippet
	Snippets        []*models.Snippet
}
...

두 번째로는 우리의 addDefaultData() helper 함수를 수정하여 해당 정보가 자동적으로 templateData 구조체에 매 template 렌더링마다 전달하도록 하자.

  • cmd/web/helpers.go
func (app *application) addDefaultData(td *templateData, r *http.Request) *templateData {
	if td == nil {
		td = &templateData{}
	}
	td.CurrentYear = time.Now().Year()
	td.Flash = app.session.PopString(r, "flash")
	td.IsAuthenticated = app.isAuthenticated(r)
	return td
}

이제 ui/html/base.layout.tmpl 파일에 navigation links를 toggle하는 코드를 넣어주도록 하자. {{if .IsAuthenticated}} action을 사용하면 된다.

  • ui/html/base.layout.tmpl
{{define "base"}}
<!doctype html>
<html lang='en'>
    <head>
        <meta charset='utf-8'>
        <title>{{template "title" .}} - Snippetbox</title>
        <link rel='stylesheet' href='/static/css/main.css'>
        <link rel='shortcut icon' href='/static/img/favicon.ico' type='image/x-icon'>
        <link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Ubuntu+Mono:400,700'>
    </head>
    <body>
        <header>
            <h1><a href='/'>Snippetbox</a></h1>
        </header>
        <nav>
        <!-- Update the navigation to include signup, login and logout links -->
            <div>
                <a href='/'>Home</a>
                <!-- Toggle the navigation link -->
                {{if .IsAuthenticated}}
                    <a href='/snippet/create'>Create snippet</a>
                {{end}}
            </div>
            <div>
                <!-- Toggle the navigation links -->
                {{if .IsAuthenticated}}
                    <form action='/user/logout' method='POST'>
                        <button>Logout</button>
                    </form>
                {{else}}
                    <a href='/user/signup'>Signup</a>
                    <a href='/user/login'>Login</a>
                {{end}}
            </div>
        </nav>
        <main>
            {{with .Flash}}
            <div class='flash'>{{.}}</div>
            {{end}}
            {{template "main" .}}
        </main>
        {{template "footer" .}}
        <script src="/static/js/main.js" type="text/javascript"></script>
    </body>
</html>
{{end}}

{{if .IsAuthenticated}}로 check하는 것은 empty value 체크로 false, 0, nil, empty array, slice, map, string 등이 있다.

이제 app server를 다시 구동한 다음 잘 동작하는 지 확인하자.

잘 동작하는 것을 확인하였다면, 인증 정보를 화면에 잘 활용한 것은 성공한 것이다. 그러나, 아직 한 가지 남았는데, 접근을 제한해야하는 부분이 있다. 만약 user가 rest api를 알게되어서 인증 정보 없이 요청한다해도, 지금은 인증 정보가 있는 지, 없는 지 확인하는 코드가 없어 그대로 실행된다. 이를 해결하기 위해서 접근을 제한해줄 필요가 있다.

12. Restricting Access

만약 유저가 https://localhost:4000/snippet/create 페이지에 직접적으로 요청을 보낸다면 어떻게 될까?? 현재는 인증정보를 검사하는 코드가 없어 그대로 실행된다. 이를 해결하기위해서 접근을 제한하는 코드를 만들어줄 필요가 있다.

만약, 인증되지 않은 요청이 오면 /snippet/create 대신에 /user/login 페이지를 보여주도록 하자.

이는 middleware.go에서 할 수 있다. 새로운 메서드인 requireAuthentication() middleware function을 추가한다음 다음의 패턴을 추가하자.

  • cmd/web/middleware.go
...

func (app *application) requireAuthentication(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// If the user is not authenticated, redirect them to the login page and
		// return from the middleware chain so that no subsequent handlers in
		// the chain are executed.
		if !app.isAuthenticated(r) {
			http.Redirect(w, r, "/user/login", http.StatusSeeOther)
			return
		}
		// Otherwise set the "Cache-Control: no-store" header so that pages
		// require authentication are not stored in the users browser cache (or
		// other intermediary cache).
		w.Header().Add("Cache-Control", "no-store")
		// And call the next handler in the chain.
		next.ServeHTTP(w, r)
	})
}

...

이제 해당 middleware를 cmd/web/routes.go 파일에 특정 routes를 보호하기 위해 추가할 수 있다.

우리의 경우 GET /snippet/createPOST /snippet/create routes를 보호하기 위해서 이를 사용할 수 있다. 또한, POST /user/logout 에도 마찬가지로 추가할 수 있다.

새로운 requireAuthentication 미들웨어를 dynamicMiddleware 체인의 각 route basis에 Append() 할 수 있다.

  • cmd/web/routes.go
package main

import (
	"net/http"

	"github.com/bmizerany/pat"
	"github.com/justinas/alice"
)

func (app *application) routes() http.Handler {
	standardMiddleware := alice.New(app.recoverPanic, app.logRequest, secureHeaders)
	dynamicMiddleware := alice.New(app.session.Enable)
	authenticatedMiddleware := dynamicMiddleware.Append(app.requireAuthentication)
	
    mux := pat.New()
	mux.Get("/", dynamicMiddleware.ThenFunc(app.home))
	mux.Get("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippetForm))
	mux.Post("/snippet/create", authenticatedMiddleware.ThenFunc(app.createSnippet))
	mux.Get("/snippet/:id", dynamicMiddleware.ThenFunc(app.showSnippet))
	// Add the five new routes.
	mux.Get("/user/signup", dynamicMiddleware.ThenFunc(app.signupUserForm))
	mux.Post("/user/signup", dynamicMiddleware.ThenFunc(app.signupUser))
	mux.Get("/user/login", dynamicMiddleware.ThenFunc(app.loginUserForm))
	mux.Post("/user/login", dynamicMiddleware.ThenFunc(app.loginUser))
	mux.Post("/user/logout", authenticatedMiddleware.ThenFunc(app.logoutUser))

	fileServer := http.FileServer(http.Dir("./ui/static/"))
	mux.Get("/static/", http.StripPrefix("/static", fileServer))
	return standardMiddleware.Then(mux)
}

dynamicMiddlewarerequireAuthenticationAppend하여 authenticatedMiddleware를 만들어 각 route들에 넣어주면 된다.

저장하고, server를 다시 시작하도록 하자.

이제 각 rest api를 가로채어 인증이 없는 요청을 보내도 실행되지 않게된다. 인증 정보를 확인하여 접근을 제한하게 된 것이다.

13. CSRF Protection

CSRFCross-Site Request Forgery attack으로 제 3자가 website에 서버의 상태를 변경하는 http request를 보내는 것이다.

CSRF 방법은 다음과 같다.

  1. user가 우리가 만든 사이트에 접속하고 로그인한다.
  2. 로그인 후에 cookie에 사용자 인증 토큰이 브라우저에 저장된다.
  3. 해커가 우리 사이트에 특정 url에 접속하도록 게시물을 올린다. ex) www.hell.com에 오시면 돈 드립니다.
  4. user가 www.hell.com에 접속하면, 해당 사이트의 특정 태그에 공격 url을 숨겨놓는다. 이 url은 우리가 만든 서버에 특정 api를 요청하도록 한다. ex) img의 src에 POST /snippets/create을 숨겨놓는다.
  5. 해당 태그의 url을 user 브라우저에서 실행하게되면 해커가 심어둔 요청이 실행된다. ex) POST /snippets/create
  6. user의 브라우저에는 인증 토큰이 cookie에 저장되었기 때문에 해당 요청을 우리의 서버에서 토큰을 확인하고 실행하게 된다.
  7. user는 자신이 만든 적 없는 게시물을 우리의 사이트에 게시하게 된다.

우리의 경우 주요한 위협 요인은 다음과 같다.

  1. user가 login하면 우리의 session cookie는 12시간 동안 지속된다. 이는 application을 종료해도 login이 되어있다는 의미이다.
  2. user가 위험 소지가 있는 website에 접속하여 일부 코드를 통해 POST /snippets/create를 요청하면 우리의 database에 새로운 snippet이 생길 위험이 있다.

이외에도 login and logout CSRF attacks 등 application이 여러 가지 문제에 있을 위험이 있다.

0개의 댓글