이제 user 인증 기능을 우리의 application에 추가해보도록 하자. 계정을 등록하고, 오직 인증된 유저만 새로운 snippet을 만들도록 하는 것이다. 로그인 되지 않은 user는 snippet을 볼 수만 있도록 하는 것이다.
우리의 application은 다음의 process로 동작하게 된다.
/user/signup
에 있는 form을 작성함으로서 이름과 이메일, 주소, 비밀번호를 입력한다. 우리는 이 정보를 user database table에 저장한다./user/login
에 접속하여 login을 할 수 있는 페이지가 나오고 email과 password를 사용하여 인증할 수 있다.authenticatedUserId
key값을 가진다.session data
에서 authenticatedUserId
을 확인한다. 만약 존재한다면 user는 성공적으로 로그인 된 것이다. 우리는 session이 만료되기 전까지 이를 계속해서 확인한다. 만약 authenticatedUserId
에 어떠한 값도 없으면 우리는 user가 logout되었다고 판단한다.우리의 application에 5개의 routes를 추가해보도록 하자. 이는 다음과 같다.
Method | Pattern | Handler | Action |
---|---|---|---|
GET | / | home | Display the home page |
GET | /snippet/:id | showSnippet | Display a specific snippets |
GET | /snippet/create | createSnippetForm | Display the new snippet form |
POST | /snippet/create | createSnippet | Create a new snippet |
GET | /user/signup | signupUserForm | Display the user signup form |
POST | /user/signup | signupUser | Create a new user |
GET | /user/login | loginUserForm | Display the user login form |
POST | /user/login | loginUser | Authenticate and login the user |
POST | /user/logout | logoutUser | Logout the user |
GET | /static/ | http.FileServer | Serve a specific static file |
signupUser
, loginUser
그리고 logoutUser
는 POST
를 쓴다는 점을 주목하도록 하자.
handlers.go
파일을 열고 앞서 말했던 5개의 핸들러를 추가하도록 하자.
...
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
파일에 추가하면 된다.
...
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
파일을 수정해보도록 하자.
{{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}}
nav
에 Signup
, Login
, Logout
을 추가하였다. 이제 잘 동작하는 지를 확인해보자.
만들었던 버튼들이 잘나오면 성공이다.
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되어 있지 않다는 것이다.새로운 user
table과 go application이 손쉽게 동작하기위해 model을 하나 만들자.
pkg/models/model.go
파일을 열고 각 user의 정보를 갖는 User
구조체를 만들고, 추가적으로 여러 error 타입들을 만들도록 하자.
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
타입을 새로운 함수들과 함께 넣어주도록 하자.
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
를 다음과 같이 변경하도록 하자.
// 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을 실행해보면 아무 문제없이 이전과 동일하게 동작하는 것을 확인할 수 있다.
로그인 이전에 먼저 회원가입 기능인 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
에 접속하였을 때 페이지가 제대로 나왔으면 성공한 것이다.
signup 정보가 서버로 전송되면 서버가 가장 먼저해야할 일은 제대로 된 데이터가 온지를 validating하는 것이다.
우리는 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
정규 표현식 패턴에 대해서 설명하자면 다음과 같다.
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에서 다루도록 하자.
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
가 반환된다.
다음으로 우리가 해야할 것은 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
라는 에러가 나온다면 성공이다.
이제 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가 나왔다면 성공한 것이다.
다음은 user에 의해 제출된 email과 password를 검증하여 이것이 정확한 가를 확인하는 것이다.
검증 로직의 가장 큰 핵심 기능으로 UserModel.Authenticate()
을 user model에 만들어주도록 하자. 여기에는 두 가지 필요한 점이 있다.
첫번째로 우리의 MySQL users
table로 부터 email 주소와 연관된 hashed password를 가져와야한다. 만약 email이 database에서 없다면 또는 user가 deactivated되어 있다면 우리는 이전에 만든 ErrInvalidCredentials
를 반환할 것이다.
만약 이를 통과하면 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하다면 우리는 user
의 id
정보를 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
라는 정보가 나온다면 성공한 것이다.
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시키도록 하자.
문제는 우리는 authenticatedUserID
session data로 어떠한 인증을 하지 않았다는 것이다. 즉, Create snippet
버튼을 누르는 것은 로그인이 된 유저만 가능하도록 한다던지 login이 되었다는 검증을 하지 않고 프로그램을 구동하고 있었다.
authorization된 유저들은 다음의 작업을 할 수 있다.
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 렌더링마다 전달하도록 하자.
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를 알게되어서 인증 정보 없이 요청한다해도, 지금은 인증 정보가 있는 지, 없는 지 확인하는 코드가 없어 그대로 실행된다. 이를 해결하기 위해서 접근을 제한해줄 필요가 있다.
만약 유저가 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/create
과 POST /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)
}
dynamicMiddleware
에 requireAuthentication
를 Append
하여 authenticatedMiddleware
를 만들어 각 route들에 넣어주면 된다.
저장하고, server를 다시 시작하도록 하자.
이제 각 rest api를 가로채어 인증이 없는 요청을 보내도 실행되지 않게된다. 인증 정보를 확인하여 접근을 제한하게 된 것이다.
CSRF
는 Cross-Site Request Forgery
attack으로 제 3자가 website에 서버의 상태를 변경하는 http request를 보내는 것이다.
CSRF
방법은 다음과 같다.
www.hell.com
에 오시면 돈 드립니다.www.hell.com
에 접속하면, 해당 사이트의 특정 태그에 공격 url
을 숨겨놓는다. 이 url
은 우리가 만든 서버에 특정 api를 요청하도록 한다. ex) img의 src에 POST /snippets/create
을 숨겨놓는다.url
을 user 브라우저에서 실행하게되면 해커가 심어둔 요청이 실행된다. ex) POST /snippets/create
우리의 경우 주요한 위협 요인은 다음과 같다.
POST /snippets/create
를 요청하면 우리의 database에 새로운 snippet이 생길 위험이 있다.이외에도 login and logout
CSRF attacks 등 application이 여러 가지 문제에 있을 위험이 있다.