diff --git a/assets/migrations/1_user.down.sql b/assets/migrations/1_user.down.sql new file mode 100644 index 0000000..b1472cf --- /dev/null +++ b/assets/migrations/1_user.down.sql @@ -0,0 +1,4 @@ +DROP TABLE "reset"; +DROP TABLE "confirmation"; +DROP TABLE "email"; +DROP TABLE "user"; diff --git a/assets/migrations/1_user.up.sql b/assets/migrations/1_user.up.sql new file mode 100644 index 0000000..a626ca6 --- /dev/null +++ b/assets/migrations/1_user.up.sql @@ -0,0 +1,37 @@ +CREATE TABLE "user" ( + "id" bigserial NOT NULL, + "is_admin" boolean NOT NULL DEFAULT false, + "password" bytea NULL, + "created_at" timestamptz NOT NULL DEFAULT NOW(), + PRIMARY KEY ("id") +); + +CREATE TABLE "email" ( + "address" text NOT NULL, + "user_id" bigint NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT NOW(), + FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, + PRIMARY KEY ("address") +); +CREATE INDEX ON "email" ("user_id"); + +CREATE TABLE "confirmation" ( + "email_address" text NOT NULL, + "user_id" bigint NOT NULL, + "selector" text NOT NULL, + "verifier" bytea NOT NULL, -- hashed + "expires_at" timestamptz NOT NULL DEFAULT NOW(), + FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, + PRIMARY KEY ("selector") +); +CREATE INDEX ON "confirmation" ("user_id"); + +CREATE TABLE "reset" ( + "user_id" bigint NOT NULL, + "selector" text NOT NULL, + "verifier" bytea NOT NULL, -- hashed + "expires_at" timestamptz NOT NULL DEFAULT NOW(), + FOREIGN KEY ("user_id") REFERENCES "user" ("id") ON DELETE CASCADE, + PRIMARY KEY ("selector") +); +CREATE UNIQUE INDEX ON "reset" ("user_id"); diff --git a/assets/templates/layouts/auth_login.tmpl b/assets/templates/layouts/auth_login.tmpl new file mode 100644 index 0000000..cd8b7ae --- /dev/null +++ b/assets/templates/layouts/auth_login.tmpl @@ -0,0 +1,24 @@ + + + +
+ + + +{{ . }}
+ {{- end }} + + + + \ No newline at end of file diff --git a/internal/database/email.xo.go b/internal/database/email.xo.go new file mode 100644 index 0000000..17aaf6e --- /dev/null +++ b/internal/database/email.xo.go @@ -0,0 +1,231 @@ +// Package database contains the types for schema 'public'. +package database + +// Code generated by xo. DO NOT EDIT. + +import ( + "errors" + "time" +) + +// Email represents a row from '"public"."email"'. +type Email struct { + Address string `db:"address"` // address + UserID int64 `db:"user_id"` // user_id + CreatedAt time.Time `db:"created_at"` // created_at + + // xo fields + _exists, _deleted bool +} + +// Exists determines if the Email exists in the database. +func (e *Email) Exists() bool { + return e._exists +} + +// Deleted provides information if the Email has been deleted from the database. +func (e *Email) Deleted() bool { + return e._deleted +} + +// Insert inserts the Email to the database. +func (e *Email) Insert(db XODB) error { + var err error + + // if already exist, bail + if e._exists { + return errors.New("insert failed: already exists") + } + + // sql insert query, primary key must be provided + const sqlstr = `INSERT INTO "public"."email" (` + + `"address", "user_id", "created_at"` + + `) VALUES (` + + `$1, $2, $3` + + `)` + + // run query + XOLog(sqlstr, e.Address, e.UserID, e.CreatedAt) + _, err = db.Exec(sqlstr, e.Address, e.UserID, e.CreatedAt) + if err != nil { + return err + } + + // set existence + e._exists = true + + return nil +} + +// Update updates the Email in the database. +func (e *Email) Update(db XODB) error { + var err error + + // if doesn't exist, bail + if !e._exists { + return errors.New("update failed: does not exist") + } + + // if deleted, bail + if e._deleted { + return errors.New("update failed: marked for deletion") + } + + // sql query + const sqlstr = `UPDATE "public"."email" SET (` + + `"user_id", "created_at"` + + `) = ( ` + + `$1, $2` + + `) WHERE "address" = $3` + + // run query + XOLog(sqlstr, e.UserID, e.CreatedAt, e.Address) + _, err = db.Exec(sqlstr, e.UserID, e.CreatedAt, e.Address) + return err +} + +// Save saves the Email to the database. +func (e *Email) Save(db XODB) error { + if e.Exists() { + return e.Update(db) + } + + return e.Insert(db) +} + +// Upsert performs an upsert for Email. +// +// NOTE: PostgreSQL 9.5+ only +func (e *Email) Upsert(db XODB) error { + var err error + + // if already exist, bail + if e._exists { + return errors.New("insert failed: already exists") + } + + // sql query + const sqlstr = `INSERT INTO "public"."email" (` + + `"address", "user_id", "created_at"` + + `) VALUES (` + + `$1, $2, $3` + + `) ON CONFLICT ("address") DO UPDATE SET (` + + `"address", "user_id", "created_at"` + + `) = (` + + `EXCLUDED."address", EXCLUDED."user_id", EXCLUDED."created_at"` + + `)` + + // run query + XOLog(sqlstr, e.Address, e.UserID, e.CreatedAt) + _, err = db.Exec(sqlstr, e.Address, e.UserID, e.CreatedAt) + if err != nil { + return err + } + + // set existence + e._exists = true + + return nil +} + +// Delete deletes the Email from the database. +func (e *Email) Delete(db XODB) error { + var err error + + // if doesn't exist, bail + if !e._exists { + return nil + } + + // if deleted, bail + if e._deleted { + return nil + } + + // sql query + const sqlstr = `DELETE FROM "public"."email" WHERE "address" = $1` + + // run query + XOLog(sqlstr, e.Address) + _, err = db.Exec(sqlstr, e.Address) + if err != nil { + return err + } + + // set deleted + e._deleted = true + + return nil +} + +// User returns the User associated with the Email's UserID (user_id). +// +// Generated from foreign key 'email_user_id_fkey'. +func (e *Email) User(db XODB) (*User, error) { + return UserByID(db, e.UserID) +} + +// EmailByAddress retrieves a row from '"public"."email"' as a Email. +// +// Generated from index 'email_pkey'. +func EmailByAddress(db XODB, address string) (*Email, error) { + var err error + + // sql query + const sqlstr = `SELECT ` + + `"address", "user_id", "created_at" ` + + `FROM "public"."email" ` + + `WHERE "address" = $1` + + // run query + XOLog(sqlstr, address) + e := Email{ + _exists: true, + } + + err = db.QueryRow(sqlstr, address).Scan(&e.Address, &e.UserID, &e.CreatedAt) + if err != nil { + return nil, err + } + + return &e, nil +} + +// EmailsByUserID retrieves a row from '"public"."email"' as a Email. +// +// Generated from index 'email_user_id_idx'. +func EmailsByUserID(db XODB, userID int64) ([]*Email, error) { + var err error + + // sql query + const sqlstr = `SELECT ` + + `"address", "user_id", "created_at" ` + + `FROM "public"."email" ` + + `WHERE "user_id" = $1` + + // run query + XOLog(sqlstr, userID) + q, err := db.Query(sqlstr, userID) + if err != nil { + return nil, err + } + defer q.Close() + + // load results + res := []*Email{} + for q.Next() { + e := Email{ + _exists: true, + } + + // scan + err = q.Scan(&e.Address, &e.UserID, &e.CreatedAt) + if err != nil { + return nil, err + } + + res = append(res, &e) + } + + return res, nil +} diff --git a/internal/database/user.xo.go b/internal/database/user.xo.go new file mode 100644 index 0000000..b3eb9c5 --- /dev/null +++ b/internal/database/user.xo.go @@ -0,0 +1,186 @@ +// Package database contains the types for schema 'public'. +package database + +// Code generated by xo. DO NOT EDIT. + +import ( + "errors" + "time" +) + +// User represents a row from '"public"."user"'. +type User struct { + ID int64 `db:"id"` // id + IsAdmin bool `db:"is_admin"` // is_admin + Password []byte `db:"password"` // password + CreatedAt time.Time `db:"created_at"` // created_at + + // xo fields + _exists, _deleted bool +} + +// Exists determines if the User exists in the database. +func (u *User) Exists() bool { + return u._exists +} + +// Deleted provides information if the User has been deleted from the database. +func (u *User) Deleted() bool { + return u._deleted +} + +// Insert inserts the User to the database. +func (u *User) Insert(db XODB) error { + var err error + + // if already exist, bail + if u._exists { + return errors.New("insert failed: already exists") + } + + // sql insert query, primary key provided by sequence + const sqlstr = `INSERT INTO "public"."user" (` + + `"is_admin", "password", "created_at"` + + `) VALUES (` + + `$1, $2, $3` + + `) RETURNING "id"` + + // run query + XOLog(sqlstr, u.IsAdmin, u.Password, u.CreatedAt) + err = db.QueryRow(sqlstr, u.IsAdmin, u.Password, u.CreatedAt).Scan(&u.ID) + if err != nil { + return err + } + + // set existence + u._exists = true + + return nil +} + +// Update updates the User in the database. +func (u *User) Update(db XODB) error { + var err error + + // if doesn't exist, bail + if !u._exists { + return errors.New("update failed: does not exist") + } + + // if deleted, bail + if u._deleted { + return errors.New("update failed: marked for deletion") + } + + // sql query + const sqlstr = `UPDATE "public"."user" SET (` + + `"is_admin", "password", "created_at"` + + `) = ( ` + + `$1, $2, $3` + + `) WHERE "id" = $4` + + // run query + XOLog(sqlstr, u.IsAdmin, u.Password, u.CreatedAt, u.ID) + _, err = db.Exec(sqlstr, u.IsAdmin, u.Password, u.CreatedAt, u.ID) + return err +} + +// Save saves the User to the database. +func (u *User) Save(db XODB) error { + if u.Exists() { + return u.Update(db) + } + + return u.Insert(db) +} + +// Upsert performs an upsert for User. +// +// NOTE: PostgreSQL 9.5+ only +func (u *User) Upsert(db XODB) error { + var err error + + // if already exist, bail + if u._exists { + return errors.New("insert failed: already exists") + } + + // sql query + const sqlstr = `INSERT INTO "public"."user" (` + + `"id", "is_admin", "password", "created_at"` + + `) VALUES (` + + `$1, $2, $3, $4` + + `) ON CONFLICT ("id") DO UPDATE SET (` + + `"id", "is_admin", "password", "created_at"` + + `) = (` + + `EXCLUDED."id", EXCLUDED."is_admin", EXCLUDED."password", EXCLUDED."created_at"` + + `)` + + // run query + XOLog(sqlstr, u.ID, u.IsAdmin, u.Password, u.CreatedAt) + _, err = db.Exec(sqlstr, u.ID, u.IsAdmin, u.Password, u.CreatedAt) + if err != nil { + return err + } + + // set existence + u._exists = true + + return nil +} + +// Delete deletes the User from the database. +func (u *User) Delete(db XODB) error { + var err error + + // if doesn't exist, bail + if !u._exists { + return nil + } + + // if deleted, bail + if u._deleted { + return nil + } + + // sql query + const sqlstr = `DELETE FROM "public"."user" WHERE "id" = $1` + + // run query + XOLog(sqlstr, u.ID) + _, err = db.Exec(sqlstr, u.ID) + if err != nil { + return err + } + + // set deleted + u._deleted = true + + return nil +} + +// UserByID retrieves a row from '"public"."user"' as a User. +// +// Generated from index 'user_pkey'. +func UserByID(db XODB, id int64) (*User, error) { + var err error + + // sql query + const sqlstr = `SELECT ` + + `"id", "is_admin", "password", "created_at" ` + + `FROM "public"."user" ` + + `WHERE "id" = $1` + + // run query + XOLog(sqlstr, id) + u := User{ + _exists: true, + } + + err = db.QueryRow(sqlstr, id).Scan(&u.ID, &u.IsAdmin, &u.Password, &u.CreatedAt) + if err != nil { + return nil, err + } + + return &u, nil +} diff --git a/internal/database/xo_db.xo.go b/internal/database/xo_db.xo.go new file mode 100644 index 0000000..8547b69 --- /dev/null +++ b/internal/database/xo_db.xo.go @@ -0,0 +1,85 @@ +// Package database contains the types for schema 'public'. +package database + +// Code generated by xo. DO NOT EDIT. + +import ( + "database/sql" + "database/sql/driver" + "encoding/csv" + "errors" + "fmt" + "regexp" + "strings" +) + +// XODB is the common interface for database operations that can be used with +// types from schema 'public'. +// +// This should work with database/sql.DB and database/sql.Tx. +type XODB interface { + Exec(string, ...interface{}) (sql.Result, error) + Query(string, ...interface{}) (*sql.Rows, error) + QueryRow(string, ...interface{}) *sql.Row +} + +// XOLog provides the log func used by generated queries. +var XOLog = func(string, ...interface{}) {} + +// ScannerValuer is the common interface for types that implement both the +// database/sql.Scanner and sql/driver.Valuer interfaces. +type ScannerValuer interface { + sql.Scanner + driver.Valuer +} + +// StringSlice is a slice of strings. +type StringSlice []string + +// quoteEscapeRegex is the regex to match escaped characters in a string. +var quoteEscapeRegex = regexp.MustCompile(`([^\\]([\\]{2})*)\\"`) + +// Scan satisfies the sql.Scanner interface for StringSlice. +func (ss *StringSlice) Scan(src interface{}) error { + buf, ok := src.([]byte) + if !ok { + return errors.New("invalid StringSlice") + } + + // change quote escapes for csv parser + str := quoteEscapeRegex.ReplaceAllString(string(buf), `$1""`) + str = strings.Replace(str, `\\`, `\`, -1) + + // remove braces + str = str[1 : len(str)-1] + + // bail if only one + if len(str) == 0 { + *ss = StringSlice([]string{}) + return nil + } + + // parse with csv reader + cr := csv.NewReader(strings.NewReader(str)) + slice, err := cr.Read() + if err != nil { + fmt.Printf("exiting!: %v\n", err) + return err + } + + *ss = StringSlice(slice) + + return nil +} + +// Value satisfies the driver.Valuer interface for StringSlice. +func (ss StringSlice) Value() (driver.Value, error) { + v := make([]string, len(ss)) + for i, s := range ss { + v[i] = `"` + strings.Replace(strings.Replace(s, `\`, `\\\`, -1), `"`, `\"`, -1) + `"` + } + return "{" + strings.Join(v, ",") + "}", nil +} + +// Slice is a slice of ScannerValuers. +type Slice []ScannerValuer diff --git a/internal/web/handlers.go b/internal/web/handlers.go index ae60f5d..2130023 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -3,8 +3,9 @@ package web import ( "net/http" - "github.com/alexedwards/scs" "bitmask.me/skeleton/internal/app" + "github.com/alexedwards/scs" + "github.com/gorilla/csrf" ) type Handlers struct { @@ -24,6 +25,22 @@ func (h *Handlers) Session() *scs.Session { return h.session } +func (h *Handlers) commonRenderContext(r *http.Request) map[string]interface{} { + return map[string]interface{}{ + csrf.TemplateTag: csrf.TemplateField(r), + "Username": h.Session().GetString(r.Context(), SessKeyUserName), + "UserID": h.Session().GetString(r.Context(), SessKeyUserID), + } +} + +func (h *Handlers) CSRF() func(http.Handler) http.Handler { + return csrf.Protect( + []byte("12345678901234567890123456789012"), + csrf.FieldName("authenticity_token"), + csrf.Secure(h.session.Cookie.Secure), + ) +} + func (h *Handlers) LandingPageHandler(w http.ResponseWriter, r *http.Request) { h.Templates().Get("landing.tmpl").Execute(w, nil) } diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go new file mode 100644 index 0000000..ff1961e --- /dev/null +++ b/internal/web/handlers_auth.go @@ -0,0 +1,98 @@ +package web + +import ( + "net/http" + "strconv" + + "golang.org/x/crypto/bcrypt" + + "bitmask.me/skeleton/internal/database" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" +) + +// ErrNotImplemented is returned whenever a feature is not implemented yet. +var ErrNotImplemented = errors.New("Not implemented") + +// User interface is provided by all data types that are returned from a +// User store. +type User interface { + GetID() string + GetDisplayName() string +} + +// UserRow wraps a user row from the database. +type UserRow struct { + *database.User +} + +// GetDisplayName implements User interface by returning the display name. +func (u UserRow) GetDisplayName() string { + return u.GetID() +} + +// GetID implements the User interface by returning the user ID. +func (u UserRow) GetID() string { + return strconv.FormatInt(u.ID, 10) +} + +// NewAuthenticator returns a authable function from a Database. +func NewAuthenticator(db *sqlx.DB) func(user, pass string) (User, error) { + return func(user, pass string) (User, error) { + // Fetch email used for login + email, err := database.EmailByAddress(db, user) + if err != nil { + return nil, err + } + + row, err := email.User(db) + if err != nil { + return nil, err + } + + //u.Password + err = bcrypt.CompareHashAndPassword(row.Password, []byte(pass)) + if err != nil { + return nil, err + } + + u := UserRow{row} + + return u, nil + + } +} + +// LoginPageHandler renders the login page, and sets session cookies +// on successful authentication. +func (h *Handlers) LoginPageHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + type LoginForm struct { + Login string + Password string + } + + loginForm := LoginForm{ + Login: r.PostFormValue("login"), + Password: r.PostFormValue("password"), + } + + authenticate := NewAuthenticator(h.App.Database()) + + user, err := authenticate(loginForm.Login, loginForm.Password) + if err != nil { + context := h.commonRenderContext(r) + context["Errors"] = []string{"Wrong username or password"} + h.Templates().Get("auth_login.tmpl").Execute(w, context) + return + } + + sess := h.Session() + sess.Put(r.Context(), SessKeyUserID, user.GetID()) + sess.Put(r.Context(), SessKeyUserName, user.GetDisplayName()) + http.Redirect(w, r, "/app", http.StatusFound) + return + } + + h.Templates().Get("auth_login.tmpl").Execute(w, h.commonRenderContext(r)) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index 53d2c10..5b79c4a 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -4,9 +4,9 @@ import ( "net/http" "strings" + "bitmask.me/skeleton/internal/app" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" - "bitmask.me/skeleton/internal/app" ) func registerRoutes(ac *app.App, r chi.Router) { @@ -20,6 +20,9 @@ func registerRoutes(ac *app.App, r chi.Router) { h.Session().LoadAndSave, ) + r.Get("/login", h.LoginPageHandler) + r.Post("/login", h.LoginPageHandler) + r.Get("/", h.LandingPageHandler) r.Route("/app", func(r chi.Router) {