From 3fd8bf7e6901ac2c38e1611f590c6af661eadc8a Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 13 Jul 2020 14:44:05 +0200 Subject: [PATCH] =?UTF-8?q?Auto-cr=C3=A9ation=20du=20compte=20utilisateur?= =?UTF-8?q?=20=C3=A0=20la=20premi=C3=A8re=20connexion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sauvegarde de l'adresse courriel de l'utilisateur en session - Implémentation d'une première Query GraphQL pour récupérer le profil de l'utilisateur connecté - Utilisation de la pattern CQRS pour les commandes/requêtes sur la base de données --- cmd/server/cqrs.go | 28 ++++++++- cmd/server/migration.go | 2 + internal/command/create_user.go | 99 ++++++++++++++++++++++++++++++ internal/database/service.go | 4 +- internal/graph/model/models_gen.go | 10 ++- internal/graph/schema.graphqls | 4 ++ internal/graph/schema.resolvers.go | 39 +++++++++++- internal/query/find_user.go | 71 +++++++++++++++++++++ internal/route/login.go | 46 ++++++++++++++ internal/route/mount.go | 2 + internal/session/middleware.go | 97 +++++++++++++++++++++++++++++ 11 files changed, 395 insertions(+), 7 deletions(-) create mode 100644 internal/command/create_user.go create mode 100644 internal/query/find_user.go create mode 100644 internal/session/middleware.go diff --git a/cmd/server/cqrs.go b/cmd/server/cqrs.go index ef32ed0..875c475 100644 --- a/cmd/server/cqrs.go +++ b/cmd/server/cqrs.go @@ -1,11 +1,37 @@ package main -import "gitlab.com/wpetit/goweb/service" +import ( + "forge.cadoles.com/Cadoles/daddy/internal/command" + "forge.cadoles.com/Cadoles/daddy/internal/query" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" + "gitlab.com/wpetit/goweb/service" +) func initCommands(ctn *service.Container) error { + dispatcher, err := cqrs.From(ctn) + if err != nil { + return errors.WithStack(err) + } + + dispatcher.RegisterCommand( + cqrs.MatchCommandRequest(&command.CreateUserCommandRequest{}), + cqrs.CommandHandlerFunc(command.HandleCreateUserCommand), + ) + return nil } func initQueries(ctn *service.Container) error { + dispatcher, err := cqrs.From(ctn) + if err != nil { + return errors.WithStack(err) + } + + dispatcher.RegisterQuery( + cqrs.MatchQueryRequest(&query.FindUserQueryRequest{}), + cqrs.QueryHandlerFunc(query.HandleFindUserQuery), + ) + return nil } diff --git a/cmd/server/migration.go b/cmd/server/migration.go index 7669d16..fedce18 100644 --- a/cmd/server/migration.go +++ b/cmd/server/migration.go @@ -83,6 +83,8 @@ func m000initialSchema() migration.Migration { id SERIAL PRIMARY KEY, name TEXT, email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + connected_at TIMESTAMPTZ, CONSTRAINT unique_email unique(email) ); `) diff --git a/internal/command/create_user.go b/internal/command/create_user.go new file mode 100644 index 0000000..82ac4ea --- /dev/null +++ b/internal/command/create_user.go @@ -0,0 +1,99 @@ +package command + +import ( + "context" + + "github.com/jackc/pgx/v4/pgxpool" + + "forge.cadoles.com/Cadoles/daddy/internal/database" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" + "gitlab.com/wpetit/goweb/middleware/container" +) + +const ( + createConnectedUserStatement = ` + INSERT INTO users (email, connected_at) VALUES ($1, now()) + ON CONFLICT ON CONSTRAINT unique_email + DO UPDATE SET connected_at = now(); + ` + createUserStatement = ` + INSERT INTO users (email) VALUES ($1) + ON CONFLICT ON CONSTRAINT unique_email + DO NOTHING; + ` +) + +type CreateUserCommandRequest struct { + Email string + Connected bool +} + +func HandleCreateUserCommand(ctx context.Context, cmd cqrs.Command) error { + req, ok := cmd.Request().(*CreateUserCommandRequest) + if !ok { + return errors.WithStack(cqrs.ErrUnexpectedRequest) + } + + ctn, err := container.From(ctx) + if err != nil { + return errors.WithStack(err) + } + + pool, err := database.From(ctn) + if err != nil { + return errors.WithStack(err) + } + + conn, err := pool.Acquire(ctx) + if err != nil { + return errors.WithStack(err) + } + + defer conn.Release() + + if req.Connected { + if err := createConnectedUser(ctx, conn, req.Email); err != nil { + return errors.WithStack(err) + } + } else { + if err := createUser(ctx, conn, req.Email); err != nil { + return errors.WithStack(err) + } + } + + return nil +} + +func createConnectedUser(ctx context.Context, conn *pgxpool.Conn, email string) error { + _, err := conn.Conn().Prepare( + ctx, "create_connected_user", + createConnectedUserStatement, + ) + if err != nil { + return errors.WithStack(err) + } + + if _, err := conn.Exec(ctx, "create_connected_user", email); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func createUser(ctx context.Context, conn *pgxpool.Conn, email string) error { + _, err := conn.Conn().Prepare( + ctx, "create_user", + createUserStatement, + ) + if err != nil { + return errors.WithStack(err) + } + + if _, err := conn.Exec(ctx, "create_user", email); err != nil { + return errors.WithStack(err) + } + + return nil +} diff --git a/internal/database/service.go b/internal/database/service.go index 5aabf08..cad5509 100644 --- a/internal/database/service.go +++ b/internal/database/service.go @@ -8,7 +8,7 @@ import ( const ServiceName service.Name = "database" -// From retrieves the database pool service in the given container +// From retrieves the database pool service in the given container. func From(container *service.Container) (*pgxpool.Pool, error) { service, err := container.Service(ServiceName) if err != nil { @@ -23,7 +23,7 @@ func From(container *service.Container) (*pgxpool.Pool, error) { return srv, nil } -// Must retrieves the database pool service in the given container or panic otherwise +// Must retrieves the database pool service in the given container or panic otherwise. func Must(container *service.Container) *pgxpool.Pool { srv, err := From(container) if err != nil { diff --git a/internal/graph/model/models_gen.go b/internal/graph/model/models_gen.go index 20d3076..8448226 100644 --- a/internal/graph/model/models_gen.go +++ b/internal/graph/model/models_gen.go @@ -2,7 +2,13 @@ package model +import ( + "time" +) + type User struct { - Name *string `json:"name"` - Email string `json:"email"` + Name *string `json:"name"` + Email string `json:"email"` + ConnectedAt time.Time `json:"connectedAt"` + CreatedAt time.Time `json:"createdAt"` } diff --git a/internal/graph/schema.graphqls b/internal/graph/schema.graphqls index 38e0313..7ae4e20 100644 --- a/internal/graph/schema.graphqls +++ b/internal/graph/schema.graphqls @@ -2,9 +2,13 @@ # # https://gqlgen.com/getting-started/ +scalar Time + type User { name: String email: String! + connectedAt: Time! + createdAt: Time! } type Query { diff --git a/internal/graph/schema.resolvers.go b/internal/graph/schema.resolvers.go index 67cbdbd..6549cd6 100644 --- a/internal/graph/schema.resolvers.go +++ b/internal/graph/schema.resolvers.go @@ -5,14 +5,49 @@ package graph import ( "context" - "fmt" + + "forge.cadoles.com/Cadoles/daddy/internal/query" + + "forge.cadoles.com/Cadoles/daddy/internal/session" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" + "gitlab.com/wpetit/goweb/middleware/container" "forge.cadoles.com/Cadoles/daddy/internal/graph/generated" "forge.cadoles.com/Cadoles/daddy/internal/graph/model" ) func (r *queryResolver) UserProfile(ctx context.Context) (*model.User, error) { - panic(fmt.Errorf("not implemented")) + userEmail, err := session.UserEmail(ctx) + if err != nil { + return nil, errors.WithStack(err) + } + + ctn, err := container.From(ctx) + if err != nil { + return nil, errors.WithStack(err) + } + + dispatcher, err := cqrs.From(ctn) + if err != nil { + return nil, errors.WithStack(err) + } + + qry := &query.FindUserQueryRequest{ + Email: userEmail, + } + + result, err := dispatcher.Query(ctx, qry) + if err != nil { + return nil, errors.WithStack(err) + } + + findUserData, ok := result.Data().(*query.FindUserData) + if !ok { + return nil, errors.WithStack(cqrs.ErrUnexpectedData) + } + + return findUserData.User, nil } // Query returns generated.QueryResolver implementation. diff --git a/internal/query/find_user.go b/internal/query/find_user.go new file mode 100644 index 0000000..7212754 --- /dev/null +++ b/internal/query/find_user.go @@ -0,0 +1,71 @@ +package query + +import ( + "context" + + "forge.cadoles.com/Cadoles/daddy/internal/graph/model" + + "forge.cadoles.com/Cadoles/daddy/internal/database" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" + "gitlab.com/wpetit/goweb/middleware/container" +) + +const ( + findUserStatement = `SELECT email, connected_at, created_at FROM users WHERE email = $1` +) + +type FindUserQueryRequest struct { + Email string +} + +type FindUserData struct { + User *model.User +} + +func HandleFindUserQuery(ctx context.Context, qry cqrs.Query) (interface{}, error) { + req, ok := qry.Request().(*FindUserQueryRequest) + if !ok { + return nil, errors.WithStack(cqrs.ErrUnexpectedRequest) + } + + ctn, err := container.From(ctx) + if err != nil { + return nil, errors.WithStack(err) + } + + pool, err := database.From(ctn) + if err != nil { + return nil, errors.WithStack(err) + } + + conn, err := pool.Acquire(ctx) + if err != nil { + return nil, errors.WithStack(err) + } + + defer conn.Release() + + _, err = conn.Conn().Prepare( + ctx, "find_user", + findUserStatement, + ) + if err != nil { + return nil, errors.WithStack(err) + } + + user := &model.User{} + + err = conn.QueryRow(ctx, "find_user", req.Email). + Scan(&user.Email, &user.ConnectedAt, &user.CreatedAt) + if err != nil { + return nil, errors.WithStack(err) + } + + data := &FindUserData{ + User: user, + } + + return data, nil +} diff --git a/internal/route/login.go b/internal/route/login.go index bfaa7ca..6db2c4b 100644 --- a/internal/route/login.go +++ b/internal/route/login.go @@ -3,6 +3,13 @@ package route import ( "net/http" + "forge.cadoles.com/Cadoles/daddy/internal/command" + "gitlab.com/wpetit/goweb/cqrs" + + "forge.cadoles.com/Cadoles/daddy/internal/session" + + "github.com/pkg/errors" + "forge.cadoles.com/Cadoles/daddy/internal/config" oidc "forge.cadoles.com/wpetit/goweb-oidc" "gitlab.com/wpetit/goweb/logger" @@ -15,6 +22,11 @@ func handleLogin(w http.ResponseWriter, r *http.Request) { client.Login(w, r) } +type emailClaims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + func handleLoginCallback(w http.ResponseWriter, r *http.Request) { ctx := r.Context() ctn := container.Must(ctx) @@ -31,5 +43,39 @@ func handleLoginCallback(w http.ResponseWriter, r *http.Request) { logger.Info(ctx, "user logged in", logger.F("sub", idToken.Subject)) + claims := &emailClaims{} + if err := idToken.Claims(claims); err != nil { + panic(errors.WithStack(err)) + } + + // TODO implements better UX in case of errors + + if claims.Email == "" { + http.Error(w, "an email is expected to access this app", http.StatusForbidden) + + return + } + + if !claims.EmailVerified { + http.Error(w, "your email must be verified to access this app", http.StatusForbidden) + + return + } + + dispatcher := cqrs.Must(ctn) + + cmd := &command.CreateUserCommandRequest{ + Email: claims.Email, + Connected: true, + } + + if _, err := dispatcher.Exec(ctx, cmd); err != nil { + panic(errors.WithStack(err)) + } + + if err := session.SaveUserEmail(w, r, claims.Email); err != nil { + panic(errors.WithStack(err)) + } + http.Redirect(w, r, conf.HTTP.FrontendURL, http.StatusSeeOther) } diff --git a/internal/route/mount.go b/internal/route/mount.go index 6538247..860def4 100644 --- a/internal/route/mount.go +++ b/internal/route/mount.go @@ -4,6 +4,7 @@ import ( "forge.cadoles.com/Cadoles/daddy/internal/config" "forge.cadoles.com/Cadoles/daddy/internal/graph" "forge.cadoles.com/Cadoles/daddy/internal/graph/generated" + "forge.cadoles.com/Cadoles/daddy/internal/session" oidc "forge.cadoles.com/wpetit/goweb-oidc" "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/playground" @@ -21,6 +22,7 @@ func Mount(r *chi.Mux, config *config.Config) error { r.Route("/api", func(r chi.Router) { r.Use(oidc.Middleware) + r.Use(session.UserEmailMiddleware) gql := handler.NewDefaultServer( generated.NewExecutableSchema(generated.Config{ diff --git a/internal/session/middleware.go b/internal/session/middleware.go new file mode 100644 index 0000000..6120bc9 --- /dev/null +++ b/internal/session/middleware.go @@ -0,0 +1,97 @@ +package session + +import ( + "context" + "net/http" + + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/middleware/container" + "gitlab.com/wpetit/goweb/service/session" +) + +type contextKey string + +const userEmailKey contextKey = "user_email" + +var ( + ErrUserEmailNotFound = errors.New("user email not found") +) + +func UserEmailMiddleware(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + userEmail, err := GetUserEmail(w, r) + if err != nil { + panic(errors.Wrap(err, "could not find user email")) + } + + ctx := WithUserEmail(r.Context(), userEmail) + r = r.WithContext(ctx) + + next.ServeHTTP(w, r) + } + + return http.HandlerFunc(fn) +} + +func WithUserEmail(ctx context.Context, email string) context.Context { + return context.WithValue(ctx, userEmailKey, email) +} + +func UserEmail(ctx context.Context) (string, error) { + email, ok := ctx.Value(userEmailKey).(string) + if !ok { + return "", errors.WithStack(ErrUserEmailNotFound) + } + + return email, nil +} + +func SaveUserEmail(w http.ResponseWriter, r *http.Request, email string) error { + sess, err := getSession(w, r) + if err != nil { + return errors.WithStack(err) + } + + sess.Set(string(userEmailKey), email) + + if err := sess.Save(w, r); err != nil { + return errors.WithStack(err) + } + + return nil +} + +func GetUserEmail(w http.ResponseWriter, r *http.Request) (string, error) { + sess, err := getSession(w, r) + if err != nil { + return "", errors.WithStack(err) + } + + email, ok := sess.Get(string(userEmailKey)).(string) + if !ok { + return "", errors.WithStack(ErrUserEmailNotFound) + } + + return email, nil +} + +func getSession(w http.ResponseWriter, r *http.Request) (session.Session, error) { + ctx := r.Context() + + ctn, err := container.From(ctx) + if err != nil { + return nil, errors.WithStack(err) + } + + session, err := session.From(ctn) + if err != nil { + return nil, errors.WithStack(err) + } + + sess, err := session.Get(w, r) + if err != nil { + return nil, errors.WithStack(err) + } + + return sess, nil +}