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:
parent
a096b506e2
commit
3fd8bf7e69
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
99
internal/command/create_user.go
Normal file
99
internal/command/create_user.go
Normal 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
|
||||||
|
}
|
@ -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 {
|
||||||
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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.
|
||||||
|
71
internal/query/find_user.go
Normal file
71
internal/query/find_user.go
Normal 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
|
||||||
|
}
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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{
|
||||||
|
97
internal/session/middleware.go
Normal file
97
internal/session/middleware.go
Normal 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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user