Skip to content

Commit

Permalink
Add login and signup pages (#12)
Browse files Browse the repository at this point in the history
* Validate password

* Return conflict response when email is not unique

* Improve login errors

* Update tests after login-signup changes

* Add login skeleton

* Draft signup page

* Fix-me

* Submit login

* Redirect to activation page after signup

* Draft google login

* Make login working

* Add session context

* Implement fetch hook and session context

* Add backend function to retrieve current user

* Complete login flow
  • Loading branch information
polldo authored May 20, 2023
1 parent 66a5a8c commit c2ee1cb
Show file tree
Hide file tree
Showing 20 changed files with 537 additions and 52 deletions.
24 changes: 13 additions & 11 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,17 @@ import (

// APIConfig contains all the mandatory dependencies required by handlers.
type APIConfig struct {
CorsOrigin string
Log logrus.FieldLogger
DB *sqlx.DB
Session *scs.SessionManager
Mailer token.Mailer
Background *background.Background
Paypal *paypal.Client
Stripe *stripecl.API
StripeCfg config.Stripe
Providers map[string]auth.Provider
CorsOrigin string
Log logrus.FieldLogger
DB *sqlx.DB
Session *scs.SessionManager
Mailer token.Mailer
Background *background.Background
Paypal *paypal.Client
Stripe *stripecl.API
StripeCfg config.Stripe
Providers map[string]auth.Provider
LoginRedirectURL string
}

// api represents our server api.
Expand Down Expand Up @@ -80,12 +81,13 @@ func APIMux(cfg APIConfig) http.Handler {
a.Handle(http.MethodPost, "/auth/login", auth.HandleLogin(cfg.DB, cfg.Session))
a.Handle(http.MethodPost, "/auth/logout", auth.HandleLogout(cfg.Session))
a.Handle(http.MethodGet, "/auth/oauth-login/{provider}", auth.HandleOauthLogin(cfg.Session, cfg.Providers))
a.Handle(http.MethodGet, "/auth/oauth-callback/{provider}", auth.HandleOauthCallback(cfg.DB, cfg.Session, cfg.Providers))
a.Handle(http.MethodGet, "/auth/oauth-callback/{provider}", auth.HandleOauthCallback(cfg.DB, cfg.Session, cfg.Providers, cfg.LoginRedirectURL))

a.Handle(http.MethodPost, "/tokens", token.HandleToken(cfg.DB, cfg.Mailer, cfg.Background))
a.Handle(http.MethodPost, "/tokens/activate", token.HandleActivation(cfg.DB))
a.Handle(http.MethodPost, "/tokens/recover", token.HandleRecovery(cfg.DB))

a.Handle(http.MethodGet, "/users/current", user.HandleShowCurrent(cfg.DB), authen)
a.Handle(http.MethodGet, "/users/{id}", user.HandleShow(cfg.DB), authen)
a.Handle(http.MethodPost, "/users", user.HandleCreate(cfg.DB), authen)

Expand Down
2 changes: 1 addition & 1 deletion api/middleware/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ func Cors(origin string) web.Middleware {
h := func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {

w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")

return handler(ctx, w, r)
}

Expand Down
4 changes: 2 additions & 2 deletions api/test/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,8 @@ func (at *authTest) signupNoPasswordConfirm(t *testing.T) {
}

_, err := Signup(at.Server, usr)
if err == nil {
t.Fatal("cannot create user without password confirm field")
if err != nil {
t.Fatal("unexpected error: password confirm field is optional")
}
}

Expand Down
8 changes: 4 additions & 4 deletions api/test/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ func (ut *userTest) getUserOK(t *testing.T) user.User {
usr, err := Signup(ut.Server, user.UserSignup{
Name: "Paolo Calao",
Email: "[email protected]",
Password: "pass",
PasswordConfirm: "pass",
Password: "pass12345678",
PasswordConfirm: "pass12345678",
})

if err != nil {
Expand All @@ -49,7 +49,7 @@ func (ut *userTest) getUserOK(t *testing.T) user.User {
t.Fatal(err)
}

if err := Login(ut.Server, "[email protected]", "pass"); err != nil {
if err := Login(ut.Server, "[email protected]", "pass12345678"); err != nil {
t.Fatal(err)
}

Expand Down Expand Up @@ -285,7 +285,7 @@ func (ut *userTest) createUserExistent(t *testing.T) {
}
defer w.Body.Close()

if w.StatusCode != http.StatusBadRequest {
if w.StatusCode != http.StatusConflict {
t.Fatal("cannot create already existing user")
}
}
Expand Down
21 changes: 11 additions & 10 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,17 @@ func Run(logger *logrus.Logger) error {

// Construct the mux for the API calls.
mux := api.APIMux(api.APIConfig{
CorsOrigin: cfg.Cors.Origin,
Log: logger,
DB: db,
Session: sessionManager,
Mailer: mail,
Background: bg,
Paypal: pp,
Stripe: strp,
StripeCfg: cfg.Stripe,
Providers: oauthProvs,
CorsOrigin: cfg.Cors.Origin,
Log: logger,
DB: db,
Session: sessionManager,
Mailer: mail,
Background: bg,
Paypal: pp,
Stripe: strp,
StripeCfg: cfg.Stripe,
Providers: oauthProvs,
LoginRedirectURL: cfg.Oauth.LoginRedirectURL,
})

// Construct a server to service the requests against the mux.
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type Paypal struct {

type Oauth struct {
DiscoveryTimeout time.Duration `conf:"default:30s"`
LoginRedirectURL string `conf:"default:http://mylocal:3000/dashboard"`
Google struct {
Client string
Secret string
Expand Down
25 changes: 14 additions & 11 deletions core/auth/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func HandleLogin(db *sqlx.DB, session *scs.SessionManager) web.Handler {
email, pass, ok := r.BasicAuth()
if !ok {
err := errors.New("must provide email and password in Basic auth")
return weberr.NewError(err, err.Error(), http.StatusUnauthorized)
return weberr.NewError(err, err.Error(), http.StatusBadRequest)
}

u, err := user.FetchByEmail(ctx, db, email)
Expand All @@ -36,16 +36,16 @@ func HandleLogin(db *sqlx.DB, session *scs.SessionManager) web.Handler {
return weberr.NotAuthorized(err)
}

if !u.Active {
err := fmt.Errorf("user %s is not active yet", u.Email)
return weberr.NewError(err, err.Error(), http.StatusForbidden)
}

err = bcrypt.CompareHashAndPassword(u.PasswordHash, []byte(pass))
if err != nil {
return weberr.NotAuthorized(err)
}

if !u.Active {
err := fmt.Errorf("user %s is not active yet", u.Email)
return weberr.NewError(err, err.Error(), http.StatusLocked)
}

// TODO: Save the entire user struct in the session
// or just some info?
session.Put(ctx, userKey, u.ID)
Expand Down Expand Up @@ -74,12 +74,11 @@ func HandleOauthLogin(session *scs.SessionManager, provs map[string]Provider) we
url := prov.AuthCodeURL(state)

session.Put(ctx, oauthKey, state)
http.Redirect(w, r, url, http.StatusSeeOther)
return nil
return web.Respond(ctx, w, url, http.StatusOK)
}
}

func HandleOauthCallback(db *sqlx.DB, session *scs.SessionManager, provs map[string]Provider) web.Handler {
func HandleOauthCallback(db *sqlx.DB, session *scs.SessionManager, provs map[string]Provider, redirect string) web.Handler {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
p := web.Param(r, "provider")
prov, ok := provs[p]
Expand Down Expand Up @@ -161,7 +160,8 @@ func HandleOauthCallback(db *sqlx.DB, session *scs.SessionManager, provs map[str
return err
}

return web.Respond(ctx, w, nil, http.StatusNoContent)
http.Redirect(w, r, redirect, http.StatusFound)
return nil
}
}

Expand All @@ -187,7 +187,7 @@ func HandleSignup(db *sqlx.DB) web.Handler {
}

if err := validate.Check(u); err != nil {
return fmt.Errorf("validating data: %w", err)
return weberr.NewError(err, err.Error(), http.StatusUnprocessableEntity)
}

hash, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
Expand All @@ -209,6 +209,9 @@ func HandleSignup(db *sqlx.DB) web.Handler {
}

if err := user.Create(ctx, db, usr); err != nil {
if errors.Is(err, user.ErrUniqueEmail) {
return weberr.NewError(err, err.Error(), http.StatusConflict)
}
return err
}

Expand Down
21 changes: 18 additions & 3 deletions core/user/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/polldo/govod/api/web"
"github.com/polldo/govod/api/weberr"
"github.com/polldo/govod/core/claims"
"github.com/polldo/govod/database"
"github.com/polldo/govod/validate"
"golang.org/x/crypto/bcrypt"
)
Expand Down Expand Up @@ -51,8 +50,8 @@ func HandleCreate(db *sqlx.DB) web.Handler {
}

if err := Create(ctx, db, usr); err != nil {
if errors.Is(err, database.ErrDBDuplicatedEntry) {
return weberr.NewError(err, err.Error(), http.StatusBadRequest)
if errors.Is(err, ErrUniqueEmail) {
return weberr.NewError(err, err.Error(), http.StatusConflict)
}
return err
}
Expand Down Expand Up @@ -81,3 +80,19 @@ func HandleShow(db *sqlx.DB) web.Handler {
return web.Respond(ctx, w, user, http.StatusOK)
}
}

func HandleShowCurrent(db *sqlx.DB) web.Handler {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
clm, err := claims.Get(ctx)
if err != nil {
return weberr.NotAuthorized(errors.New("user not authenticated"))
}

user, err := Fetch(ctx, db, clm.UserID)
if err != nil {
return fmt.Errorf("ID[%s]: %w", clm.UserID, err)
}

return web.Respond(ctx, w, user, http.StatusOK)
}
}
7 changes: 7 additions & 0 deletions core/user/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import (
"github.com/polldo/govod/database"
)

var (
ErrUniqueEmail = errors.New("email is not unique")
)

func Create(ctx context.Context, db sqlx.ExtContext, user User) error {
const q = `
INSERT INTO users
Expand All @@ -18,6 +22,9 @@ func Create(ctx context.Context, db sqlx.ExtContext, user User) error {
(:user_id, :name, :email, :password_hash, :role, :active, :created_at, :updated_at)`

if err := database.NamedExecContext(ctx, db, q, user); err != nil {
if errors.Is(err, database.ErrDBDuplicatedEntry) {
return ErrUniqueEmail
}
return fmt.Errorf("inserting user: %w", err)
}

Expand Down
4 changes: 2 additions & 2 deletions core/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ type UserNew struct {
type UserSignup struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password"`
Password string `json:"password" validate:"required,gte=8,lte=50"`
PasswordConfirm string `json:"password_confirm" validate:"omitempty,eqfield=Password"`
}

type UserUp struct {
Expand Down
Loading

0 comments on commit c2ee1cb

Please sign in to comment.