Auto-création du compte utilisateur à la première connexion

- 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
This commit is contained in:
wpetit 2020-07-13 14:44:05 +02:00
parent a096b506e2
commit 3fd8bf7e69
11 changed files with 395 additions and 7 deletions

View File

@ -1,11 +1,37 @@
package main 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 { 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 return nil
} }
func initQueries(ctn *service.Container) error { 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 return nil
} }

View File

@ -83,6 +83,8 @@ func m000initialSchema() migration.Migration {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name TEXT, name TEXT,
email TEXT NOT NULL, email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
connected_at TIMESTAMPTZ,
CONSTRAINT unique_email unique(email) CONSTRAINT unique_email unique(email)
); );
`) `)

View File

@ -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
}

View File

@ -8,7 +8,7 @@ import (
const ServiceName service.Name = "database" 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) { func From(container *service.Container) (*pgxpool.Pool, error) {
service, err := container.Service(ServiceName) service, err := container.Service(ServiceName)
if err != nil { if err != nil {
@ -23,7 +23,7 @@ func From(container *service.Container) (*pgxpool.Pool, error) {
return srv, nil 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 { func Must(container *service.Container) *pgxpool.Pool {
srv, err := From(container) srv, err := From(container)
if err != nil { if err != nil {

View File

@ -2,7 +2,13 @@
package model package model
import (
"time"
)
type User struct { type User struct {
Name *string `json:"name"` Name *string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
ConnectedAt time.Time `json:"connectedAt"`
CreatedAt time.Time `json:"createdAt"`
} }

View File

@ -2,9 +2,13 @@
# #
# https://gqlgen.com/getting-started/ # https://gqlgen.com/getting-started/
scalar Time
type User { type User {
name: String name: String
email: String! email: String!
connectedAt: Time!
createdAt: Time!
} }
type Query { type Query {

View File

@ -5,14 +5,49 @@ package graph
import ( import (
"context" "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/generated"
"forge.cadoles.com/Cadoles/daddy/internal/graph/model" "forge.cadoles.com/Cadoles/daddy/internal/graph/model"
) )
func (r *queryResolver) UserProfile(ctx context.Context) (*model.User, error) { 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. // Query returns generated.QueryResolver implementation.

View File

@ -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
}

View File

@ -3,6 +3,13 @@ package route
import ( import (
"net/http" "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" "forge.cadoles.com/Cadoles/daddy/internal/config"
oidc "forge.cadoles.com/wpetit/goweb-oidc" oidc "forge.cadoles.com/wpetit/goweb-oidc"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/logger"
@ -15,6 +22,11 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
client.Login(w, r) client.Login(w, r)
} }
type emailClaims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
func handleLoginCallback(w http.ResponseWriter, r *http.Request) { func handleLoginCallback(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
ctn := container.Must(ctx) 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)) 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) http.Redirect(w, r, conf.HTTP.FrontendURL, http.StatusSeeOther)
} }

View File

@ -4,6 +4,7 @@ import (
"forge.cadoles.com/Cadoles/daddy/internal/config" "forge.cadoles.com/Cadoles/daddy/internal/config"
"forge.cadoles.com/Cadoles/daddy/internal/graph" "forge.cadoles.com/Cadoles/daddy/internal/graph"
"forge.cadoles.com/Cadoles/daddy/internal/graph/generated" "forge.cadoles.com/Cadoles/daddy/internal/graph/generated"
"forge.cadoles.com/Cadoles/daddy/internal/session"
oidc "forge.cadoles.com/wpetit/goweb-oidc" oidc "forge.cadoles.com/wpetit/goweb-oidc"
"github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground" "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.Route("/api", func(r chi.Router) {
r.Use(oidc.Middleware) r.Use(oidc.Middleware)
r.Use(session.UserEmailMiddleware)
gql := handler.NewDefaultServer( gql := handler.NewDefaultServer(
generated.NewExecutableSchema(generated.Config{ generated.NewExecutableSchema(generated.Config{

View File

@ -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
}