diff --git a/.gitignore b/.gitignore index 2a12031..f53e065 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ aws.lee aws.* .env_docker_compose +local.env diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2e1a96e..469735e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -92,6 +92,7 @@ webapp:deploy:dev: STATIC_FILES_S3: 'true' STATIC_FILES_IMG_RESIZE: 'true' AWS_USE_ROLE: 'true' + EMAIL_SENDER: 'lee@geeksinthewoods.com' webapi:build:dev: <<: *build_tmpl @@ -131,6 +132,7 @@ webapi:deploy:dev: STATIC_FILES_S3: 'false' STATIC_FILES_IMG_RESIZE: 'false' AWS_USE_ROLE: 'true' + EMAIL_SENDER: 'lee@geeksinthewoods.com' #ddlogscollector:deploy:stage: # <<: *deploy_stage_tmpl diff --git a/README.md b/README.md index f5a3af1..244fd0a 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ schema migrations before running any unit tests. To login to the local Postgres container, use the following command: ```bash docker exec -it saas-starter-kit_postgres_1 /bin/bash -bash-4.4# psql -U postgres shared +bash-5.0# psql -U postgres shared ``` The example project currently only includes a few tables. As more functionality is built into both the web-app and diff --git a/cmd/web-api/Dockerfile b/cmd/web-api/Dockerfile index f71045a..27b6450 100644 --- a/cmd/web-api/Dockerfile +++ b/cmd/web-api/Dockerfile @@ -37,6 +37,9 @@ COPY cmd/web-api ./cmd/web-api COPY cmd/web-api/templates /templates #COPY cmd/web-api/static /static +# Copy the global templates. +ADD resources/templates/shared /templates/shared + WORKDIR ./cmd/web-api # Update the API documentation. @@ -54,6 +57,8 @@ COPY --from=builder /gosrv / COPY --from=builder /templates /templates ENV TEMPLATE_DIR=/templates +ENV SHARED_TEMPLATE_DIR=/templates/shared +#ENV STATIC_DIR=/static ARG service ENV SERVICE_NAME $service diff --git a/cmd/web-api/ecs-task-definition.json b/cmd/web-api/ecs-task-definition.json index 3f622d4..38511be 100644 --- a/cmd/web-api/ecs-task-definition.json +++ b/cmd/web-api/ecs-task-definition.json @@ -38,6 +38,7 @@ {"name": "WEB_API_SERVICE_BASE_URL", "value": "{APP_BASE_URL}"}, {"name": "WEB_API_SERVICE_HOST_NAMES", "value": "{HOST_NAMES}"}, {"name": "WEB_API_SERVICE_ENABLE_HTTPS", "value": "{HTTPS_ENABLED}"}, + {"name": "WEB_API_EMAIL_SENDER", "value": "{EMAIL_SENDER}"}, {"name": "WEB_API_REDIS_HOST", "value": "{CACHE_HOST}"}, {"name": "WEB_API_DB_HOST", "value": "{DB_HOST}"}, {"name": "WEB_API_DB_USER", "value": "{DB_USER}"}, diff --git a/cmd/web-api/sample.env b/cmd/web-api/sample.env index e73a8d9..b7e0571 100644 --- a/cmd/web-api/sample.env +++ b/cmd/web-api/sample.env @@ -2,3 +2,4 @@ export WEB_API_DB_HOST=127.0.0.1:5433 export WEB_API_DB_USER=postgres export WEB_API_DB_PASS=postgres export WEB_API_DB_DISABLE_TLS=true +export WEB_API_SERVICE_EMAIL_SENDER=valdez@example.com diff --git a/cmd/web-app/Dockerfile b/cmd/web-app/Dockerfile index 5bfdd9b..43ab472 100644 --- a/cmd/web-app/Dockerfile +++ b/cmd/web-app/Dockerfile @@ -24,6 +24,9 @@ COPY cmd/web-app ./cmd/web-app COPY cmd/web-app/templates /templates COPY cmd/web-app/static /static +# Copy the global templates. +ADD resources/templates/shared /templates/shared + WORKDIR ./cmd/web-app RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /gosrv . @@ -38,6 +41,7 @@ COPY --from=builder /static /static COPY --from=builder /templates /templates ENV TEMPLATE_DIR=/templates +ENV SHARED_TEMPLATE_DIR=/templates/shared ENV STATIC_DIR=/static ARG service diff --git a/cmd/web-app/ecs-task-definition.json b/cmd/web-app/ecs-task-definition.json index 9c23b4c..f12d181 100644 --- a/cmd/web-app/ecs-task-definition.json +++ b/cmd/web-app/ecs-task-definition.json @@ -42,6 +42,7 @@ {"name": "WEB_APP_SERVICE_STATICFILES_S3_PREFIX", "value": "{STATIC_FILES_S3_PREFIX}"}, {"name": "WEB_APP_SERVICE_STATICFILES_CLOUDFRONT_ENABLED", "value": "{STATIC_FILES_CLOUDFRONT_ENABLED}"}, {"name": "WEB_APP_SERVICE_STATICFILES_IMG_RESIZE_ENABLED", "value": "{STATIC_FILES_IMG_RESIZE_ENABLED}"}, + {"name": "WEB_APP_EMAIL_SENDER", "value": "{EMAIL_SENDER}"}, {"name": "WEB_APP_REDIS_HOST", "value": "{CACHE_HOST}"}, {"name": "WEB_APP_DB_HOST", "value": "{DB_HOST}"}, {"name": "WEB_APP_DB_USER", "value": "{DB_USER}"}, diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index 8afab02..fb04b51 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -3,6 +3,7 @@ package handlers import ( "context" "fmt" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "log" "net/http" "os" @@ -23,7 +24,7 @@ const ( ) // API returns a handler for a set of routes. -func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, projectRoutes project_routes.ProjectRoutes, renderer web.Renderer, globalMids ...web.Middleware) http.Handler { +func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, projectRoutes project_routes.ProjectRoutes, secretKey string, notifyEmail notify.Email, renderer web.Renderer, globalMids ...web.Middleware) http.Handler { // Define base middlewares applied to all requests. middlewares := []web.Middleware{ @@ -50,13 +51,18 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir MasterDB: masterDB, Renderer: renderer, Authenticator: authenticator, + ProjectRoutes: projectRoutes, + NotifyEmail: notifyEmail, + SecretKey: secretKey, } // This route is not authenticated app.Handle("POST", "/user/login", u.Login) app.Handle("GET", "/user/login", u.Login) app.Handle("GET", "/user/logout", u.Logout) - app.Handle("POST", "/user/forgot-password", u.ForgotPassword) - app.Handle("GET", "/user/forgot-password", u.ForgotPassword) + app.Handle("POST", "/user/reset-password/:hash", u.ResetConfirm) + app.Handle("GET", "/user/reset-password/:hash", u.ResetConfirm) + app.Handle("POST", "/user/reset-password", u.ResetPassword) + app.Handle("GET", "/user/reset-password", u.ResetPassword) // Register user management and authentication endpoints. s := Signup{ diff --git a/cmd/web-app/handlers/user.go b/cmd/web-app/handlers/user.go index 3679b5d..a00aa02 100644 --- a/cmd/web-app/handlers/user.go +++ b/cmd/web-app/handlers/user.go @@ -2,6 +2,8 @@ package handlers import ( "context" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" + project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" "net/http" "time" @@ -23,6 +25,9 @@ type User struct { MasterDB *sqlx.DB Renderer web.Renderer Authenticator *auth.Authenticator + ProjectRoutes project_routes.ProjectRoutes + NotifyEmail notify.Email + SecretKey string } type UserLoginRequest struct { @@ -156,7 +161,156 @@ func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Reques } // List returns all the existing users in the system. -func (h *User) ForgotPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-forgot-password.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil) + ctxValues, err := webcontext.ContextValues(ctx) + if err != nil { + return err + } + + // + req := new(user.UserResetPasswordRequest) + data := make(map[string]interface{}) + f := func() error { + + if r.Method == http.MethodPost { + err := r.ParseForm() + if err != nil { + return err + } + + decoder := schema.NewDecoder() + if err := decoder.Decode(req, r.PostForm); err != nil { + return err + } + + if err := webcontext.Validator().Struct(req); err != nil { + if ne, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = ne.(*weberror.Error) + return nil + } else { + return err + } + } + + _, err = user.ResetPassword(ctx, h.MasterDB, h.ProjectRoutes.UserResetPassword, h.NotifyEmail, *req, h.SecretKey, ctxValues.Now) + if err != nil { + switch errors.Cause(err) { + default: + if verr, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = verr.(*weberror.Error) + return nil + } else { + return err + } + } + } + + // Display a flash message!!! + } + + return nil + } + + if err := f(); err != nil { + return web.RenderError(ctx, w, r, err, h.Renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8) + } + + data["form"] = req + + if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(user.UserResetPasswordRequest{})); ok { + data["validationDefaults"] = verr.(*weberror.Error) + } + + return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-reset-password.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) +} + +// List returns all the existing users in the system. +func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + + ctxValues, err := webcontext.ContextValues(ctx) + if err != nil { + return err + } + + // + req := new(user.UserResetConfirmRequest) + data := make(map[string]interface{}) + f := func() error { + + if r.Method == http.MethodPost { + err := r.ParseForm() + if err != nil { + return err + } + + decoder := schema.NewDecoder() + if err := decoder.Decode(req, r.PostForm); err != nil { + return err + } + + if err := webcontext.Validator().Struct(req); err != nil { + if ne, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = ne.(*weberror.Error) + return nil + } else { + return err + } + } + + u, err := user.ResetConfirm(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now) + if err != nil { + switch errors.Cause(err) { + default: + if verr, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = verr.(*weberror.Error) + return nil + } else { + return err + } + } + } + + // Authenticated the user. Probably should use the default session TTL from UserLogin. + token, err := user.Authenticate(ctx, h.MasterDB, h.Authenticator, u.Email, req.Password, time.Hour, ctxValues.Now) + if err != nil { + switch errors.Cause(err) { + case account.ErrForbidden: + return web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden)) + default: + if verr, ok := weberror.NewValidationError(ctx, err); ok { + data["validationErrors"] = verr.(*weberror.Error) + return nil + } else { + return err + } + } + } + + // Add the token to the users session. + err = handleSessionToken(ctx, w, r, token) + if err != nil { + return err + } + + // Redirect the user to the dashboard. + http.Redirect(w, r, "/", http.StatusFound) + } else { + req.ResetHash = params["hash"] + } + + return nil + } + + if err := f(); err != nil { + return web.RenderError(ctx, w, r, err, h.Renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8) + } + + data["form"] = req + + if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(user.UserResetConfirmRequest{})); ok { + data["validationDefaults"] = verr.(*weberror.Error) + } + + return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-reset-confirm.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) } diff --git a/cmd/web-app/main.go b/cmd/web-app/main.go index 87fb116..01f13ac 100644 --- a/cmd/web-app/main.go +++ b/cmd/web-app/main.go @@ -6,6 +6,8 @@ import ( "encoding/json" "expvar" "fmt" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" + "gopkg.in/gomail.v2" "html/template" "log" "net" @@ -81,13 +83,14 @@ func main() { DisableHTTP2 bool `default:"false" envconfig:"DISABLE_HTTP2"` } Service struct { - Name string `default:"web-app" envconfig:"NAME"` - Project string `default:"" envconfig:"PROJECT"` - BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://eproc.tech"` - HostNames []string `envconfig:"HOST_NAMES" example:"www.eproc.tech"` - EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` - TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` - StaticFiles struct { + Name string `default:"web-app" envconfig:"NAME"` + Project string `default:"" envconfig:"PROJECT"` + BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://eproc.tech"` + HostNames []string `envconfig:"HOST_NAMES" example:"www.eproc.tech"` + EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` + TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` + SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"` + StaticFiles struct { Dir string `default:"./static" envconfig:"STATIC_DIR"` S3Enabled bool `envconfig:"S3_ENABLED"` S3Prefix string `default:"public/web_app/static" envconfig:"S3_PREFIX"` @@ -97,6 +100,7 @@ func main() { WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.eproc.tech"` SessionKey string `default:"" envconfig:"SESSION_KEY"` SessionName string `default:"" envconfig:"SESSION_NAME"` + EmailSender string `default:"" envconfig:"EMAIL_SENDER"` DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"` ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` } @@ -137,6 +141,12 @@ func main() { UseAwsSecretManager bool `default:"false" envconfig:"USE_AWS_SECRET_MANAGER"` KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"` } + STMP struct { + Host string `default:"localhost" envconfig:"HOST"` + Port int `default:"25" envconfig:"PORT"` + User string `default:"" envconfig:"USER"` + Pass string `default:"" envconfig:"PASS" json:"-"` // don't print + } BuildInfo struct { CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"` CiCommitRefSlug string `envconfig:"CI_COMMIT_REF_SLUG"` @@ -351,6 +361,38 @@ func main() { } defer masterDb.Close() + // ========================================================================= + // Notify Email + var notifyEmail notify.Email + if awsSession != nil { + notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender) + if err != nil { + log.Fatalf("main : Notify Email : %+v", err) + } + + err = notifyEmail.Verify() + if err != nil { + switch errors.Cause(err) { + case notify.ErrAwsSesIdentityNotVerified: + log.Printf("main : Notify Email : %s\n", err) + case notify.ErrAwsSesSendingDisabled: + log.Printf("main : Notify Email : %s\n", err) + default: + log.Fatalf("main : Notify Email Verify : %+v", err) + } + } + } else { + d := gomail.Dialer{ + Host: cfg.STMP.Host, + Port: cfg.STMP.Port, + Username: cfg.STMP.User, + Password: cfg.STMP.Pass} + notifyEmail, err = notify.NewEmailSmtp(d, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender) + if err != nil { + log.Fatalf("main : Notify Email : %+v", err) + } + } + // ========================================================================= // Init new Authenticator var authenticator *auth.Authenticator @@ -776,7 +818,7 @@ func main() { if cfg.HTTP.Host != "" { api := http.Server{ Addr: cfg.HTTP.Host, - Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...), ReadTimeout: cfg.HTTP.ReadTimeout, WriteTimeout: cfg.HTTP.WriteTimeout, MaxHeaderBytes: 1 << 20, @@ -793,7 +835,7 @@ func main() { if cfg.HTTPS.Host != "" { api := http.Server{ Addr: cfg.HTTPS.Host, - Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...), ReadTimeout: cfg.HTTPS.ReadTimeout, WriteTimeout: cfg.HTTPS.WriteTimeout, MaxHeaderBytes: 1 << 20, diff --git a/cmd/web-app/sample.env b/cmd/web-app/sample.env index 9377321..cb9bedc 100644 --- a/cmd/web-app/sample.env +++ b/cmd/web-app/sample.env @@ -2,3 +2,4 @@ export WEB_APP_DB_HOST=127.0.0.1:5433 export WEB_APP_DB_USER=postgres export WEB_APP_DB_PASS=postgres export WEB_APP_DB_DISABLE_TLS=true +export WEB_APP_SERVICE_EMAIL_SENDER=valdez@example.com \ No newline at end of file diff --git a/cmd/web-app/static/assets/js/custom.js b/cmd/web-app/static/assets/js/custom.js index 02525d0..869a01a 100644 --- a/cmd/web-app/static/assets/js/custom.js +++ b/cmd/web-app/static/assets/js/custom.js @@ -1 +1,25 @@ -console.log("test"); \ No newline at end of file + +$(document).ready(function() { + + // Prevent duplicate validation messages. When the validation error is displayed inline + // when the form value, don't display the form error message at the top of the page. + $(this).find('#page-content form').find('input, select, textarea').each(function(index){ + var fname = $(this).attr('name'); + if (fname === undefined) { + return; + } + + var vnode = $(this).parent().find('div.invalid-feedback'); + var formField = $(vnode).attr('data-field'); + $(document).find('div.validation-error').find('li').each(function(){ + if ($(this).attr('data-form-field') == formField) { + if ($(vnode).is(":visible")) { + $(this).hide(); + } else { + console.log('form validation feedback for '+fname+' is not visable, display main.'); + } + } + }); + }); + +}); diff --git a/cmd/web-app/templates/content/signup-step1.gohtml b/cmd/web-app/templates/content/signup-step1.gohtml index 5502991..95669a0 100644 --- a/cmd/web-app/templates/content/signup-step1.gohtml +++ b/cmd/web-app/templates/content/signup-step1.gohtml @@ -3,7 +3,7 @@ {{end}} {{ define "partials/page-wrapper" }} -
.....
+We get it, stuff happens. Just enter your email address below and we'll send you a link to reset your password!
+