diff --git a/internal/api/handlers.go b/internal/api/handlers.go index bd7d095..e03dd89 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -21,9 +21,26 @@ type IndexPageData struct { BasePageData } +type SignupForm struct { + Email string + Password string + Valid bool +} + type SignupPageData struct { Title string - SignupDetails + SignupForm +} + +type LoginForm struct { + Email string + Password string + Valid bool +} + +type LoginPageData struct { + Title string + LoginForm } type AttributionsPageData struct { @@ -82,22 +99,16 @@ func (a *APIConfig) HandleSignupPage(w http.ResponseWriter, r *http.Request) { } } -type SignupDetails struct { - Email string - Password string - Valid bool -} - func (a *APIConfig) HandlePostSignup(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - signupDetails := SignupDetails{ + signupDetails := SignupForm{ Email: r.FormValue("email"), Password: r.FormValue("password"), } signupPageData := SignupPageData{ - Title: "TailScribe - Sign Up", - SignupDetails: signupDetails, + Title: "TailScribe - Sign Up", + SignupForm: signupDetails, } tmpl := template.Must(template.ParseFiles( @@ -154,10 +165,11 @@ func (a *APIConfig) HandlePostSignup(w http.ResponseWriter, r *http.Request) { return } - tokenString, err := auth.MakeJWT(user.ID, a.Env.Secret) + err = a.createAndAttachSessionCookies(&w, user) if err != nil { + log.Fatal(err) signupDetails.Valid = false - w.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(http.StatusBadRequest) err = tmpl.Execute(w, signupPageData) if err != nil { log.Fatal(err) @@ -165,65 +177,127 @@ func (a *APIConfig) HandlePostSignup(w http.ResponseWriter, r *http.Request) { return } - refreshTokenString, err := auth.MakeRefreshToken() + http.Redirect(w, r, "/add_new_pet", http.StatusFound) +} + +func expireCookie(w *http.ResponseWriter, cookie_name string) { + http.SetCookie(*w, &http.Cookie{ + Name: cookie_name, + Value: "", + Expires: time.Unix(0, 0), + HttpOnly: true, + }) +} + +func (a *APIConfig) createAndAttachSessionCookies( + w *http.ResponseWriter, + user database.User, +) error { + tokenString, err := auth.MakeJWT(user.ID, a.Env.Secret) if err != nil { - signupDetails.Valid = false - w.WriteHeader(http.StatusInternalServerError) - err = tmpl.Execute(w, signupPageData) - if err != nil { - log.Fatal(err) - } - return + return err } - newPetPageData := BasePageData{ - Title: "Add a new Pet", + refreshTokenString, err := auth.MakeRefreshToken() + if err != nil { + return err } - // Create new template that points to new pet page. - tmpl = template.Must(template.ParseFiles( - "./templates/new_pet.html", - "./templates/base.html", - )) - - http.SetCookie(w, &http.Cookie{ + http.SetCookie(*w, &http.Cookie{ Name: "token", Value: tokenString, Expires: time.Now().Add(time.Hour * 24), HttpOnly: true, Secure: true, - Domain: "/", + // Domain: "/", SameSite: http.SameSiteStrictMode, }) - http.SetCookie(w, &http.Cookie{ + http.SetCookie(*w, &http.Cookie{ Name: "refresh_token", Value: refreshTokenString, Expires: time.Now().Add(time.Hour * 30 * 24), HttpOnly: true, - Domain: "/", + // Domain: "/", SameSite: http.SameSiteStrictMode, }) - w.WriteHeader(http.StatusCreated) - err = tmpl.Execute(w, newPetPageData) + + return nil +} + +func RejectPostLogin( + w http.ResponseWriter, + tmpl *template.Template, + loginDetails *LoginForm, + loginPageData *LoginPageData, + status int) error { + + loginDetails.Valid = false + w.WriteHeader(status) + + err := tmpl.Execute(w, loginPageData) + + return err +} + +func (a *APIConfig) HandlePostLogin(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + loginDetails := LoginForm{ + Email: r.FormValue("email"), + Password: r.FormValue("password"), + } + + loginPageData := LoginPageData{ + Title: "TailScribe - Log In", + LoginForm: loginDetails, + } + + tmpl := template.Must(template.ParseFiles( + "./templates/login.html", + "./templates/base.html", + )) + + email := sql.NullString{ + String: loginDetails.Email, + Valid: true, + } + + user, err := a.Db.GetUserByEmail(ctx, email) + if err != nil { + err = RejectPostLogin(w, tmpl, &loginDetails, &loginPageData, http.StatusUnauthorized) + if err != nil { + log.Fatal(err) + } + return + } + + valid := auth.CheckPasswordHash(loginDetails.Password, user.Password.String) + + if !valid { + err = RejectPostLogin(w, tmpl, &loginDetails, &loginPageData, http.StatusUnauthorized) + if err != nil { + log.Fatal(err) + } + return + } + + err = a.createAndAttachSessionCookies(&w, user) if err != nil { log.Fatal(err) + err = RejectPostLogin(w, tmpl, &loginDetails, &loginPageData, http.StatusInternalServerError) + if err != nil { + log.Fatal(err) + } + return } -} -func expireCookie(w *http.ResponseWriter, cookie_name string) { - http.SetCookie(*w, &http.Cookie{ - Name: cookie_name, - Value: "", - Expires: time.Unix(0, 0), - HttpOnly: true, - }) + http.Redirect(w, r, "/dashboard", http.StatusFound) } func (a *APIConfig) HandlePostLogout(w http.ResponseWriter, r *http.Request) { expireCookie(&w, "token") expireCookie(&w, "refresh_token") - http.Redirect(w, r, "/", http.StatusTemporaryRedirect) + http.Redirect(w, r, "/", http.StatusFound) } func (a *APIConfig) HandleAttributions(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index 0956cc5..696f905 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -86,7 +86,10 @@ func TestHandlePostSignup(t *testing.T) { apiCfg.HandlePostSignup(response, request) result := response.Result() - assert.Equal(t, 201, result.StatusCode) + assert.Equal(t, 302, result.StatusCode) + + assert.Equal(t, result.Header.Get("Location"), "/add_new_pet") + cookies := result.Cookies() assert.NotNil(t, cookies[0]) assert.Equal(t, "token", cookies[0].Name) @@ -137,7 +140,7 @@ func TestHandleLogout(t *testing.T) { logoutResult := logoutResponse.Result() - assert.Equal(t, 307, logoutResult.StatusCode) + assert.Equal(t, 302, logoutResult.StatusCode) logoutCookies := logoutResult.Cookies() assert.NotNil(t, logoutCookies[0]) assert.Equal(t, "token", logoutCookies[0].Name) @@ -157,7 +160,7 @@ func TestHandleLogout(t *testing.T) { logoutResult := logoutResponse.Result() - assert.Equal(t, 307, logoutResult.StatusCode) + assert.Equal(t, 302, logoutResult.StatusCode) logoutCookies := logoutResult.Cookies() assert.NotNil(t, logoutCookies[0]) assert.Equal(t, "token", logoutCookies[0].Name) @@ -168,6 +171,67 @@ func TestHandleLogout(t *testing.T) { }) } +func TestHandlePostLogin(t *testing.T) { + t.Run("Successfully logs a user in", func(t *testing.T) { + formData := url.Values{ + "email": {"testEmail2@email.com"}, + "password": {"password123"}, + } + + request, _ := http.NewRequest(http.MethodPost, "/signup", strings.NewReader(formData.Encode())) + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + apiCfg := NewAPIConfig(TestEnvVars, DbQueries) + apiCfg.HandlePostSignup(response, request) + + result := response.Result() + assert.Equal(t, 302, result.StatusCode) + + loginFormData := url.Values{ + "email": {"testEmail2@email.com"}, + "password": {"password123"}, + } + + loginRequest, _ := http.NewRequest(http.MethodPost, "/login", strings.NewReader(loginFormData.Encode())) + loginRequest.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + loginResponse := httptest.NewRecorder() + loginApiCfg := NewAPIConfig(TestEnvVars, DbQueries) + loginApiCfg.HandlePostLogin(loginResponse, loginRequest) + + loginResult := loginResponse.Result() + assert.Equal(t, 302, loginResult.StatusCode) + + assert.Equal(t, loginResult.Header.Get("Location"), "/dashboard") + + cookies := loginResult.Cookies() + assert.NotNil(t, cookies[0]) + assert.Equal(t, "token", cookies[0].Name) + assert.NotNil(t, cookies[1]) + assert.Equal(t, "refresh_token", cookies[1].Name) + }) + + t.Run("Fails to log a user in if invalid username/password", func(t *testing.T) { + loginFormData := url.Values{ + "email": {"testEmail3@email.com"}, + "password": {"password123"}, + } + + loginRequest, _ := http.NewRequest(http.MethodPost, "/login", strings.NewReader(loginFormData.Encode())) + loginRequest.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + loginResponse := httptest.NewRecorder() + loginApiCfg := NewAPIConfig(TestEnvVars, DbQueries) + loginApiCfg.HandlePostLogin(loginResponse, loginRequest) + + loginResult := loginResponse.Result() + assert.Equal(t, 401, loginResult.StatusCode) + + cookies := loginResult.Cookies() + assert.Len(t, cookies, 0) + }) +} + func TestGetAttributions(t *testing.T) { request, _ := http.NewRequest(http.MethodGet, "/attributions", nil) response := httptest.NewRecorder() diff --git a/internal/database/users.sql.go b/internal/database/users.sql.go index 5fcc529..ed08bd5 100644 --- a/internal/database/users.sql.go +++ b/internal/database/users.sql.go @@ -57,3 +57,32 @@ func (q *Queries) DeleteUsers(ctx context.Context) error { _, err := q.db.ExecContext(ctx, deleteUsers) return err } + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, email, username, firstname, lastname, password, facebook_id, reset_password_token, reset_password_expires, is_premium, premium_level, stripe_customer_id, is_deleted, created_at, updated_at +FROM users +WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email sql.NullString) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.Firstname, + &i.Lastname, + &i.Password, + &i.FacebookID, + &i.ResetPasswordToken, + &i.ResetPasswordExpires, + &i.IsPremium, + &i.PremiumLevel, + &i.StripeCustomerID, + &i.IsDeleted, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/main.go b/main.go index 5531f54..a4d6d29 100644 --- a/main.go +++ b/main.go @@ -42,6 +42,7 @@ func main() { mux.HandleFunc("/", apiCfg.HandleIndex) mux.HandleFunc("GET /signup", apiCfg.HandleSignupPage) mux.HandleFunc("POST /signup", apiCfg.HandlePostSignup) + mux.HandleFunc("POST /login", apiCfg.HandlePostLogin) mux.HandleFunc("POST /logout", apiCfg.HandlePostLogout) mux.HandleFunc("/attributions", apiCfg.HandleAttributions) mux.HandleFunc("/terms", apiCfg.HandleTerms) diff --git a/sql/queries/users.sql b/sql/queries/users.sql index 832f9c2..4638449 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -9,4 +9,9 @@ VALUES ( RETURNING *; -- name: DeleteUsers :exec -DELETE FROM users; \ No newline at end of file +DELETE FROM users; + +-- name: GetUserByEmail :one +SELECT * +FROM users +WHERE email = $1; \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..48c1cc1 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,20 @@ +{{ template "top" .}} + +{{ template "navbar" . }} + +
+
+

Welcome back!

+ +

Log in to your account

+ +
+ + + + +
+
+
+ +{{ template "bottom" }} \ No newline at end of file