From c625ace88d01160ffc8ce45ce6d6f23992a6da11 Mon Sep 17 00:00:00 2001 From: Lee Brown Date: Fri, 2 Aug 2019 15:03:32 -0800 Subject: [PATCH] Completed implimentation of forgot password --- .gitignore | 1 + .gitlab-ci.yml | 2 + README.md | 2 +- cmd/web-api/Dockerfile | 5 + cmd/web-api/ecs-task-definition.json | 1 + cmd/web-api/sample.env | 1 + cmd/web-app/Dockerfile | 4 + cmd/web-app/ecs-task-definition.json | 1 + cmd/web-app/handlers/routes.go | 12 +- cmd/web-app/handlers/user.go | 158 ++++++++- cmd/web-app/main.go | 60 +++- cmd/web-app/sample.env | 1 + cmd/web-app/static/assets/js/custom.js | 26 +- .../templates/content/signup-step1.gohtml | 6 +- .../templates/content/user-login.gohtml | 6 +- .../content/user-reset-confirm.gohtml | 69 ++++ .../content/user-reset-password.gohtml | 59 +++ cmd/web-app/templates/layouts/base.gohtml | 19 +- .../templates/partials/page-wrapper.tmpl | 8 +- go.mod | 2 + go.sum | 4 + internal/platform/notify/email.go | 60 ++++ internal/platform/notify/email_aws.go | 126 +++++++ internal/platform/notify/email_smtp.go | 66 ++++ internal/platform/tests/main.go | 5 +- internal/platform/web/web.go | 5 +- internal/platform/web/webcontext/context.go | 1 + internal/project-routes/project_routes.go | 6 + internal/user/models.go | 31 ++ internal/user/user.go | 335 +++++++++++++++++- internal/user/user_test.go | 190 +++++++++- internal/user_account/invite/invite.go | 271 ++++++++++++++ internal/user_account/invite/invite_test.go | 266 ++++++++++++++ internal/user_account/invite/models.go | 34 ++ internal/user_account/user_account.go | 7 +- .../templates/shared/emails/user_invite.html | 19 + .../templates/shared/emails/user_invite.txt | 4 + .../shared/emails/user_reset_password.html | 20 ++ .../shared/emails/user_reset_password.txt | 4 + tools/devops/cmd/cicd/service_deploy.go | 1 + 40 files changed, 1861 insertions(+), 37 deletions(-) create mode 100644 cmd/web-app/templates/content/user-reset-confirm.gohtml create mode 100644 cmd/web-app/templates/content/user-reset-password.gohtml create mode 100644 internal/platform/notify/email.go create mode 100644 internal/platform/notify/email_aws.go create mode 100644 internal/platform/notify/email_smtp.go create mode 100644 internal/user_account/invite/invite.go create mode 100644 internal/user_account/invite/invite_test.go create mode 100644 internal/user_account/invite/models.go create mode 100644 resources/templates/shared/emails/user_invite.html create mode 100644 resources/templates/shared/emails/user_invite.txt create mode 100644 resources/templates/shared/emails/user_reset_password.html create mode 100644 resources/templates/shared/emails/user_reset_password.txt 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" }} -
+
@@ -102,7 +102,7 @@
Already have an account? Login! @@ -212,4 +212,4 @@ }).change(); }); -{{end}} \ No newline at end of file +{{end}} diff --git a/cmd/web-app/templates/content/user-login.gohtml b/cmd/web-app/templates/content/user-login.gohtml index ecfe5ac..845ef7c 100644 --- a/cmd/web-app/templates/content/user-login.gohtml +++ b/cmd/web-app/templates/content/user-login.gohtml @@ -3,7 +3,7 @@ {{end}} {{ define "partials/page-wrapper" }} -
+
@@ -42,7 +42,7 @@
Create an Account! @@ -65,4 +65,4 @@ $(document).find('body').addClass('bg-gradient-primary'); }); -{{end}} \ No newline at end of file +{{end}} diff --git a/cmd/web-app/templates/content/user-reset-confirm.gohtml b/cmd/web-app/templates/content/user-reset-confirm.gohtml new file mode 100644 index 0000000..3ec7dbe --- /dev/null +++ b/cmd/web-app/templates/content/user-reset-confirm.gohtml @@ -0,0 +1,69 @@ +{{define "title"}}Reset Password{{end}} +{{define "style"}} + +{{end}} +{{ define "partials/page-wrapper" }} +
+ + +
+ +
+ +
+
+ +
+ +
+
+
+

Reset Your Password

+

.....

+
+ + {{ template "validation-error" . }} + +
+ +
+
+ + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }} +
+
+ + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }} +
+
+ +
+
+
+ + +
+
+
+
+
+ +
+ +
+ +
+{{end}} +{{define "js"}} + +{{end}} \ No newline at end of file diff --git a/cmd/web-app/templates/content/user-reset-password.gohtml b/cmd/web-app/templates/content/user-reset-password.gohtml new file mode 100644 index 0000000..6e4e72b --- /dev/null +++ b/cmd/web-app/templates/content/user-reset-password.gohtml @@ -0,0 +1,59 @@ +{{define "title"}}User Forgot Password{{end}} +{{define "style"}} + +{{end}} +{{ define "partials/page-wrapper" }} +
+ + +
+ +
+ +
+
+ +
+ +
+
+
+

Forgot Your Password?

+

We get it, stuff happens. Just enter your email address below and we'll send you a link to reset your password!

+
+
+
+ + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Email" }} +
+ +
+
+
+ + +
+
+
+
+
+ +
+ +
+ +
+{{end}} +{{define "js"}} + +{{end}} \ No newline at end of file diff --git a/cmd/web-app/templates/layouts/base.gohtml b/cmd/web-app/templates/layouts/base.gohtml index 7d48e49..49b6c55 100644 --- a/cmd/web-app/templates/layouts/base.gohtml +++ b/cmd/web-app/templates/layouts/base.gohtml @@ -99,7 +99,7 @@ {{end}} {{ define "invalid-feedback" }} -
+
{{ if ValidationErrorHasField .validationErrors .fieldName }} {{ range $verr := (ValidationFieldErrors .validationErrors .fieldName) }}{{ $verr.Display }}
{{ end }} {{ else }} @@ -127,4 +127,21 @@
{{ end }} {{ end }} +{{ end }} +{{ define "validation-error" }} + {{ if .validationErrors }} + {{ $errMsg := (ErrorMessage $._Ctx .validationErrors) }} + {{ if $errMsg }} + + {{ end }} + {{ end }} {{ end }} \ No newline at end of file diff --git a/cmd/web-app/templates/partials/page-wrapper.tmpl b/cmd/web-app/templates/partials/page-wrapper.tmpl index 87fe3fe..95115f5 100644 --- a/cmd/web-app/templates/partials/page-wrapper.tmpl +++ b/cmd/web-app/templates/partials/page-wrapper.tmpl @@ -23,13 +23,13 @@ {{ template "top-error" . }} - - - -
+
+ + {{ template "validation-error" . }} + {{ template "content" . }}
diff --git a/go.mod b/go.mod index 823f45e..02265c4 100644 --- a/go.mod +++ b/go.mod @@ -41,6 +41,7 @@ require ( github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 github.com/stretchr/testify v1.3.0 + github.com/sudo-suhas/symcrypto v1.0.0 github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 github.com/swaggo/swag v1.6.2 github.com/tinylib/msgp v1.1.0 // indirect @@ -55,6 +56,7 @@ require ( google.golang.org/appengine v1.6.1 // indirect gopkg.in/DataDog/dd-trace-go.v1 v1.16.1 gopkg.in/go-playground/validator.v9 v9.29.1 + gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce gotest.tools v2.2.0+incompatible // indirect ) diff --git a/go.sum b/go.sum index d4fc9e0..16dc271 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoH github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/sudo-suhas/symcrypto v1.0.0 h1:VG6FdACf5XeXFQUzeA++aB6snNThz0OFlmUHiCddi2s= +github.com/sudo-suhas/symcrypto v1.0.0/go.mod h1:g/faGDjhlF/DXdqp3+SQ0LmhPcv4iYaIRjcm/Q60+68= github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 h1:PyYN9JH5jY9j6av01SpfRMb+1DWg/i3MbGOKPxJ2wjM= github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E= github.com/swaggo/swag v1.6.2 h1:WQMAtT/FmMBb7g0rAuHDhG3vvdtHKJ3WZ+Ssb0p4Y6E= @@ -199,6 +201,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXa gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= +gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU= gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= diff --git a/internal/platform/notify/email.go b/internal/platform/notify/email.go new file mode 100644 index 0000000..3e84c3d --- /dev/null +++ b/internal/platform/notify/email.go @@ -0,0 +1,60 @@ +package notify + +import ( + "bytes" + "context" + html "html/template" + "path/filepath" + text "text/template" + + "github.com/pkg/errors" +) + +const ( + EmailCharSet = "UTF-8" +) + +// Email defines method need to send an email disregarding the service provider. +type Email interface { + Send(ctx context.Context, toEmail, subject, templateName string, data map[string]interface{}) error + Verify() error +} + +// MockEmail defines an implementation of the email interface for testing. +type MockEmail struct{} + +// Send an email the provided email address. +func (n *MockEmail) Send(ctx context.Context, toEmail, subject, templateName string, data map[string]interface{}) error { + return nil +} + +// Verify ensures the provider works. +func (n *MockEmail) Verify() error { + return nil +} + +func parseEmailTemplates(templateDir, templateName string, data map[string]interface{}) ([]byte, []byte, error) { + htmlFile := filepath.Join(templateDir, templateName+".html") + htmlTmpl, err := html.ParseFiles(htmlFile) + if err != nil { + return nil, nil, errors.WithMessage(err, "Failed to load HTML email template.") + } + + var htmlDat bytes.Buffer + if err := htmlTmpl.Execute(&htmlDat, data); err != nil { + return nil, nil, errors.WithMessage(err, "Failed to parse HTML email template.") + } + + txtFile := filepath.Join(templateDir, templateName+".txt") + txtTmpl, err := text.ParseFiles(txtFile) + if err != nil { + return nil, nil, errors.WithMessage(err, "Failed to load text email template.") + } + + var txtDat bytes.Buffer + if err := txtTmpl.Execute(&txtDat, data); err != nil { + return nil, nil, errors.WithMessage(err, "Failed to parse text email template.") + } + + return htmlDat.Bytes(), txtDat.Bytes(), nil +} diff --git a/internal/platform/notify/email_aws.go b/internal/platform/notify/email_aws.go new file mode 100644 index 0000000..37ddca7 --- /dev/null +++ b/internal/platform/notify/email_aws.go @@ -0,0 +1,126 @@ +package notify + +import ( + "context" + "os" + "path/filepath" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/ses" + "github.com/pkg/errors" +) + +var ( + // ErrAwsSesIdentityNotVerified + ErrAwsSesIdentityNotVerified = errors.New("AWS SES sending identity not verified.") + + // ErrAwsSesSendingDisabled + ErrAwsSesSendingDisabled = errors.New("AWS SES sending disabled.") +) + +// EmailAws defines the data needed to send an email with AWS SES. +type EmailAws struct { + awsSession *session.Session + senderEmailAddress string + templateDir string +} + +// NewEmailAws creates an implementation of the Email interface used to send email with AWS SES. +func NewEmailAws(awsSession *session.Session, sharedTemplateDir, senderEmailAddress string) (*EmailAws, error) { + + templateDir := filepath.Join(sharedTemplateDir, "emails") + if _, err := os.Stat(templateDir); os.IsNotExist(err) { + return nil, errors.WithMessage(err, "Email template directory does not exist.") + } + + if senderEmailAddress == "" { + return nil, errors.New("Sender email address is required.") + } + + return &EmailAws{ + awsSession: awsSession, + templateDir: templateDir, + senderEmailAddress: senderEmailAddress, + }, nil +} + +// Verify ensures the provider works. +func (n *EmailAws) Verify() error { + + svc := ses.New(n.awsSession) + + var isVerified bool + err := svc.ListIdentitiesPages(&ses.ListIdentitiesInput{}, func(res *ses.ListIdentitiesOutput, lastPage bool) bool { + for _, r := range res.Identities { + if *r == n.senderEmailAddress { + isVerified = true + return true + } + } + + return !lastPage + }) + if err != nil { + return errors.WithStack(err) + } + + if !isVerified { + return errors.WithMessagef(ErrAwsSesIdentityNotVerified, "Email address '%s' not verified.", n.senderEmailAddress) + } + + enabledRes, err := svc.GetAccountSendingEnabled(&ses.GetAccountSendingEnabledInput{}) + if err != nil { + return errors.WithStack(err) + } else if !*enabledRes.Enabled { + return errors.WithMessage(ErrAwsSesSendingDisabled, "Sending has not be enabled for recipients are "+ + "not verified. Submit support ticket with AWS for SES approval.") + } + + return nil +} + +// Send initials the delivery of an email the provided email address. +func (n *EmailAws) Send(ctx context.Context, toEmail, subject, templateName string, data map[string]interface{}) error { + + htmlDat, txtDat, err := parseEmailTemplates(n.templateDir, templateName, data) + if err != nil { + return err + } + + svc := ses.New(n.awsSession) + + // Assemble the email. + input := &ses.SendEmailInput{ + Destination: &ses.Destination{ + ToAddresses: []*string{ + aws.String(toEmail), + }, + }, + Message: &ses.Message{ + Body: &ses.Body{ + Html: &ses.Content{ + Charset: aws.String(EmailCharSet), + Data: aws.String(string(htmlDat)), + }, + Text: &ses.Content{ + Charset: aws.String(EmailCharSet), + Data: aws.String(string(txtDat)), + }, + }, + Subject: &ses.Content{ + Charset: aws.String(EmailCharSet), + Data: aws.String(subject), + }, + }, + Source: aws.String(n.senderEmailAddress), + } + + // Send the email + _, err = svc.SendEmail(input) + if err != nil { + return errors.WithStack(err) + } + + return nil +} diff --git a/internal/platform/notify/email_smtp.go b/internal/platform/notify/email_smtp.go new file mode 100644 index 0000000..748a7d3 --- /dev/null +++ b/internal/platform/notify/email_smtp.go @@ -0,0 +1,66 @@ +package notify + +import ( + "context" + "github.com/pkg/errors" + "gopkg.in/gomail.v2" + "os" + "path/filepath" +) + +// EmailAws defines the data needed to send an email with AWS SES. +type EmailSmtp struct { + dialer gomail.Dialer + senderEmailAddress string + templateDir string +} + +// NewEmailSmtp creates an implementation of the Email interface used to send email with SMTP. +func NewEmailSmtp(dialer gomail.Dialer, sharedTemplateDir, senderEmailAddress string) (*EmailSmtp, error) { + + if senderEmailAddress == "" { + return nil, errors.New("Sender email address is required.") + } + + templateDir := filepath.Join(sharedTemplateDir, "emails") + if _, err := os.Stat(templateDir); os.IsNotExist(err) { + return nil, errors.WithMessage(err, "Email template directory does not exist.") + } + + return &EmailSmtp{ + dialer: dialer, + templateDir: templateDir, + senderEmailAddress: senderEmailAddress, + }, nil +} + +// Verify ensures the provider works. +func (n *EmailSmtp) Verify() error { + return nil +} + +// Send initials the delivery of an email the provided email address. +func (n *EmailSmtp) Send(ctx context.Context, toEmail, subject, templateName string, data map[string]interface{}) error { + + htmlDat, txtDat, err := parseEmailTemplates(n.templateDir, templateName, data) + if err != nil { + return err + } + + m := gomail.NewMessage() + m.SetHeader("From", n.senderEmailAddress) + m.SetHeader("To", toEmail) + m.SetHeader("Subject", subject) + + m.SetBody("text/plain", string(txtDat)) + if err := n.dialer.DialAndSend(m); err != nil { + return errors.WithStack(err) + } + + m.SetBody("text/html", string(htmlDat)) + if err := n.dialer.DialAndSend(m); err != nil { + return errors.WithStack(err) + } + + return nil +} diff --git a/internal/platform/tests/main.go b/internal/platform/tests/main.go index d20e6cf..07b5ae3 100644 --- a/internal/platform/tests/main.go +++ b/internal/platform/tests/main.go @@ -127,8 +127,9 @@ func Recover(t *testing.T) { // Context returns an app level context for testing. func Context() context.Context { values := webcontext.Values{ - TraceID: uint64(time.Now().UnixNano()), - Now: time.Now(), + TraceID: uint64(time.Now().UnixNano()), + Now: time.Now(), + RequestIP: "68.69.35.104", } return context.WithValue(context.Background(), webcontext.KeyValues, &values) diff --git a/internal/platform/web/web.go b/internal/platform/web/web.go index a4e2408..2459e70 100644 --- a/internal/platform/web/web.go +++ b/internal/platform/web/web.go @@ -67,8 +67,9 @@ func (a *App) Handle(verb, path string, handler Handler, mw ...Middleware) { // Set the context with the required values to // process the request. v := webcontext.Values{ - Now: time.Now(), - Env: a.env, + Now: time.Now(), + Env: a.env, + RequestIP: RequestRealIP(r), } ctx := context.WithValue(r.Context(), webcontext.KeyValues, &v) diff --git a/internal/platform/web/webcontext/context.go b/internal/platform/web/webcontext/context.go index a9d39b3..3117936 100644 --- a/internal/platform/web/webcontext/context.go +++ b/internal/platform/web/webcontext/context.go @@ -22,6 +22,7 @@ type Values struct { SpanID uint64 StatusCode int Env Env + RequestIP string } func ContextValues(ctx context.Context) (*Values, error) { diff --git a/internal/project-routes/project_routes.go b/internal/project-routes/project_routes.go index 0786969..fb406d7 100644 --- a/internal/project-routes/project_routes.go +++ b/internal/project-routes/project_routes.go @@ -39,3 +39,9 @@ func (r ProjectRoutes) WebApiUrl(urlPath string) string { u.Path = urlPath return u.String() } + +func (r ProjectRoutes) UserResetPassword(resetId string) string { + u := r.webAppUrl + u.Path = "/user/reset-password/" + resetId + return u.String() +} diff --git a/internal/user/models.go b/internal/user/models.go index d613708..c0995c7 100644 --- a/internal/user/models.go +++ b/internal/user/models.go @@ -72,6 +72,11 @@ type UserCreateRequest struct { Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` } +// UserCreateInviteRequest contains information needed to create a new User. +type UserCreateInviteRequest struct { + Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"` +} + // UserUpdateRequest defines what information may be provided to modify an existing // User. All fields are optional so clients can send just the fields they want // changed. It uses pointer fields so we can differentiate between a field that @@ -99,6 +104,11 @@ type UserArchiveRequest struct { ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` } +// UserUnarchiveRequest defines the information needed to unarchive an user. +type UserUnarchiveRequest struct { + ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` +} + // UserFindRequest defines the possible options to search for users. By default // archived users will be excluded from response. type UserFindRequest struct { @@ -110,6 +120,27 @@ type UserFindRequest struct { IncludedArchived bool `json:"included-archived" example:"false"` } +// UserResetPasswordRequest defines the fields need to reset a user password. +type UserResetPasswordRequest struct { + Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"` + TTL time.Duration `json:"ttl,omitempty" ` +} + +// ResetHash +type ResetHash struct { + ResetID string `json:"reset_id" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + CreatedAt int `json:"created_at" validate:"required"` + ExpiresAt int `json:"expires_at" validate:"required"` + RequestIP string `json:"request_ip" validate:"required,ip" example:"69.56.104.36"` +} + +// UserResetConfirmRequest defines the fields need to reset a user password. +type UserResetConfirmRequest struct { + ResetHash string `json:"reset_hash" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + Password string `json:"password" validate:"required" example:"SecretString"` + PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"` +} + // AuthenticateRequest defines what information is required to authenticate a user. type AuthenticateRequest struct { Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"` diff --git a/internal/user/user.go b/internal/user/user.go index 5c3d5af..f3443b4 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -3,9 +3,13 @@ package user import ( "context" "database/sql" + "github.com/sudo-suhas/symcrypto" + "strconv" + "strings" "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" @@ -34,6 +38,9 @@ var ( // ErrAuthenticationFailure occurs when a user attempts to authenticate but // anything goes wrong. ErrAuthenticationFailure = errors.New("Authentication failed") + + // ErrResetExpired occurs when the the reset hash exceeds the expiration. + ErrResetExpired = errors.New("Reset expired") ) // userMapColumns is the list of columns needed for mapRowsToUser @@ -357,6 +364,74 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr return &u, nil } +// Create invite inserts a new user into the database. +func CreateInvite(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCreateInviteRequest, now time.Time) (*User, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.CreateInvite") + defer span.Finish() + + v := webcontext.Validator() + + // Validation email address is unique in the database. + uniq, err := UniqueEmail(ctx, dbConn, req.Email, "") + if err != nil { + return nil, err + } + ctx = context.WithValue(ctx, webcontext.KeyTagUnique, uniq) + + // Validate the request. + err = v.StructCtx(ctx, req) + if err != nil { + return nil, err + } + + // If the request has claims from a specific user, ensure that the user + // has the correct role for creating a new user. + if claims.Subject != "" { + // Users with the role of admin are ony allows to create users. + if !claims.HasRole(auth.RoleAdmin) { + err = errors.WithStack(ErrForbidden) + return nil, err + } + } + + // If now empty set it to the current time. + if now.IsZero() { + now = time.Now() + } + + // Always store the time as UTC. + now = now.UTC() + + // Postgres truncates times to milliseconds when storing. We and do the same + // here so the value we return is consistent with what we store. + now = now.Truncate(time.Millisecond) + + u := User{ + ID: uuid.NewRandom().String(), + Email: req.Email, + CreatedAt: now, + UpdatedAt: now, + } + + // Build the insert SQL statement. + query := sqlbuilder.NewInsertBuilder() + query.InsertInto(userTableName) + query.Cols("id", "email", "password_hash", "password_salt", "created_at", "updated_at") + query.Values(u.ID, u.Email, "", "", u.CreatedAt, u.UpdatedAt) + + // Execute the query with the provided context. + sql, args := query.Build() + sql = dbConn.Rebind(sql) + _, err = dbConn.ExecContext(ctx, sql, args...) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + err = errors.WithMessage(err, "create user failed") + return nil, err + } + + return &u, nil +} + // Read gets the specified user from the database. func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, includedArchived bool) (*User, error) { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Read") @@ -367,10 +442,10 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, i query.Where(query.Equal("id", id)) res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived) - if res == nil || len(res) == 0 { - err = errors.WithMessagef(ErrNotFound, "user %s not found", id) + if err != nil { return nil, err - } else if err != nil { + } else if res == nil || len(res) == 0 { + err = errors.WithMessagef(ErrNotFound, "user %s not found", id) return nil, err } u := res[0] @@ -604,6 +679,57 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA return nil } +// Unarchive undeletes the user from the database. +func Unarchive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUnarchiveRequest, now time.Time) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Unarchive") + defer span.Finish() + + // Validate the request. + v := webcontext.Validator() + err := v.Struct(req) + if err != nil { + return err + } + + // Ensure the claims can modify the user specified in the request. + err = CanModifyUser(ctx, claims, dbConn, req.ID) + if err != nil { + return err + } + + // If now empty set it to the current time. + if now.IsZero() { + now = time.Now() + } + + // Always store the time as UTC. + now = now.UTC() + + // Postgres truncates times to milliseconds when storing. We and do the same + // here so the value we return is consistent with what we store. + now = now.Truncate(time.Millisecond) + + // Build the update SQL statement. + query := sqlbuilder.NewUpdateBuilder() + query.Update(userTableName) + query.Set( + query.Assign("archived_at", nil), + ) + query.Where(query.Equal("id", req.ID)) + + // Execute the query with the provided context. + sql, args := query.Build() + sql = dbConn.Rebind(sql) + _, err = dbConn.ExecContext(ctx, sql, args...) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + err = errors.WithMessagef(err, "unarchive user %s failed", req.ID) + return err + } + + return nil +} + // Delete removes a user from the database. func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error { span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Delete") @@ -682,3 +808,206 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID str return nil } + +// ResetPassword sends en email to the user to allow them to reset their password. +func ResetPassword(ctx context.Context, dbConn *sqlx.DB, resetUrl func(string) string, notify notify.Email, req UserResetPasswordRequest, secretKey string, now time.Time) (string, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.ResetPassword") + defer span.Finish() + + v := webcontext.Validator() + + // Validate the request. + err := v.StructCtx(ctx, req) + if err != nil { + return "", err + } + + // Find user by email address. + var u *User + { + query := selectQuery() + query.Where(query.Equal("email", req.Email)) + + res, err := find(ctx, auth.Claims{}, dbConn, query, []interface{}{}, false) + if err != nil { + return "", err + } else if res == nil || len(res) == 0 { + err = errors.WithMessagef(ErrNotFound, "No user found using '%s'.", req.Email) + return "", err + } + u = res[0] + } + + // Update the user with a random string used to confirm the password reset. + resetId := uuid.NewRandom().String() + { + // Always store the time as UTC. + now = now.UTC() + + // Postgres truncates times to milliseconds when storing. We and do the same + // here so the value we return is consistent with what we store. + now = now.Truncate(time.Millisecond) + + // Build the update SQL statement. + query := sqlbuilder.NewUpdateBuilder() + query.Update(userTableName) + query.Set( + query.Assign("password_reset", resetId), + query.Assign("updated_at", now), + ) + query.Where(query.Equal("id", u.ID)) + + // Execute the query with the provided context. + sql, args := query.Build() + sql = dbConn.Rebind(sql) + _, err = dbConn.ExecContext(ctx, sql, args...) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + err = errors.WithMessagef(err, "Update user %s failed.", u.ID) + return "", err + } + } + + if req.TTL.Seconds() == 0 { + req.TTL = time.Minute * 90 + } + + // Load the current IP makings the request. + var requestIp string + if vals, _ := webcontext.ContextValues(ctx); vals != nil { + requestIp = vals.RequestIP + } + + // Generate a string that embeds additional information. + hashPts := []string{ + resetId, + strconv.Itoa(int(now.UTC().Unix())), + strconv.Itoa(int(now.UTC().Add(req.TTL).Unix())), + requestIp, + } + hashStr := strings.Join(hashPts, "|") + + // This returns the nonce appended with the encrypted string for "hello world". + crypto, err := symcrypto.New(secretKey) + if err != nil { + return "", errors.WithStack(err) + } + encrypted, err := crypto.Encrypt(hashStr) + if err != nil { + return "", errors.WithStack(err) + } + + data := map[string]interface{}{ + "Name": u.FirstName, + "Url": resetUrl(encrypted), + "Minutes": req.TTL.Minutes(), + } + + err = notify.Send(ctx, u.Email, "Reset your Password", "user_reset_password", data) + if err != nil { + err = errors.WithMessagef(err, "Send password reset email to %s failed.", u.Email) + return "", err + } + + return encrypted, nil +} + +// ResetConfirm updates the password for a user using the provided reset password ID. +func ResetConfirm(ctx context.Context, dbConn *sqlx.DB, req UserResetConfirmRequest, secretKey string, now time.Time) (*User, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.ResetConfirm") + defer span.Finish() + + v := webcontext.Validator() + + // Validate the request. + err := v.StructCtx(ctx, req) + if err != nil { + return nil, err + } + + crypto, err := symcrypto.New(secretKey) + if err != nil { + return nil, errors.WithStack(err) + } + hashStr, err := crypto.Decrypt(req.ResetHash) + if err != nil { + return nil, errors.WithStack(err) + } + hashPts := strings.Split(hashStr, "|") + + var hash ResetHash + if len(hashPts) == 4 { + hash.ResetID = hashPts[0] + hash.CreatedAt, _ = strconv.Atoi(hashPts[1]) + hash.ExpiresAt, _ = strconv.Atoi(hashPts[2]) + hash.RequestIP = hashPts[3] + } + + // Validate the hash. + err = v.StructCtx(ctx, hash) + if err != nil { + return nil, err + } + + if int64(hash.ExpiresAt) < now.UTC().Unix() { + err = errors.WithMessage(ErrResetExpired, "Password reset has expired.") + return nil, err + } + + // Find user by password_reset. + var u *User + { + query := selectQuery() + query.Where(query.Equal("password_reset", hash.ResetID)) + + res, err := find(ctx, auth.Claims{}, dbConn, query, []interface{}{}, false) + if err != nil { + return nil, err + } else if res == nil || len(res) == 0 { + err = errors.WithMessage(ErrNotFound, "Invalid password reset.") + return nil, err + } + u = res[0] + } + + // Save the new password for the user. + { + // Always store the time as UTC. + now = now.UTC() + + // Postgres truncates times to milliseconds when storing. We and do the same + // here so the value we return is consistent with what we store. + now = now.Truncate(time.Millisecond) + + // Generate new password hash for the provided password. + passwordSalt := uuid.NewRandom() + saltedPassword := req.Password + passwordSalt.String() + passwordHash, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost) + if err != nil { + return nil, errors.Wrap(err, "generating password hash") + } + + // Build the update SQL statement. + query := sqlbuilder.NewUpdateBuilder() + query.Update(userTableName) + query.Set( + query.Assign("password_reset", nil), + query.Assign("password_hash", passwordHash), + query.Assign("password_salt", passwordSalt), + query.Assign("updated_at", now), + ) + query.Where(query.Equal("id", u.ID)) + + // Execute the query with the provided context. + sql, args := query.Build() + sql = dbConn.Rebind(sql) + _, err = dbConn.ExecContext(ctx, sql, args...) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + err = errors.WithMessagef(err, "update password for user %s failed", u.ID) + return nil, err + } + } + + return u, nil +} diff --git a/internal/user/user_test.go b/internal/user/user_test.go index 6de345f..9140439 100644 --- a/internal/user/user_test.go +++ b/internal/user/user_test.go @@ -8,6 +8,7 @@ import ( "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" "github.com/dgrijalva/jwt-go" "github.com/google/go-cmp/cmp" @@ -583,7 +584,7 @@ func TestUpdatePassword(t *testing.T) { }, now) if err != nil { t.Log("\t\tGot :", err) - t.Fatalf("\t%s\tCreate failed.", tests.Failed) + t.Fatalf("\t%s\tUpdate password failed.", tests.Failed) } t.Logf("\t%s\tUpdatePassword ok.", tests.Success) @@ -886,6 +887,22 @@ func TestCrud(t *testing.T) { } t.Logf("\t%s\tArchive ok.", tests.Success) + // Unarchive (un-delete) the user. + err = Unarchive(ctx, tt.claims(user, accountId), test.MasterDB, UserUnarchiveRequest{ID: user.ID}, now) + if err != nil && errors.Cause(err) != tt.updateErr { + t.Logf("\t\tGot : %+v", err) + t.Logf("\t\tWant: %+v", tt.updateErr) + t.Fatalf("\t%s\tUnarchive failed.", tests.Failed) + } else if tt.updateErr == nil { + // Trying to find the archived user with the includeArchived false should result no error. + _, err = Read(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, false) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tUnarchive Read failed.", tests.Failed) + } + } + t.Logf("\t%s\tUnarchive ok.", tests.Success) + // Delete (hard-delete) the user. err = Delete(ctx, tt.claims(user, accountId), test.MasterDB, user.ID) if err != nil && errors.Cause(err) != tt.updateErr { @@ -1053,6 +1070,177 @@ func TestFind(t *testing.T) { } } +// TestResetPassword validates that reset password for a user works. +func TestResetPassword(t *testing.T) { + + t.Log("Given the need ensure a user can reset their password.") + { + ctx := tests.Context() + + now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) + + tknGen := &MockTokenGenerator{} + + // Create a new user for testing. + initPass := uuid.NewRandom().String() + user, err := Create(ctx, auth.Claims{}, test.MasterDB, UserCreateRequest{ + FirstName: "Lee", + LastName: "Brown", + Email: uuid.NewRandom().String() + "@geeksinthewoods.com", + Password: initPass, + PasswordConfirm: initPass, + }, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tCreate failed.", tests.Failed) + } + + // Create a new random account. + accountId := uuid.NewRandom().String() + err = mockAccount(accountId, user.CreatedAt) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tCreate account failed.", tests.Failed) + } + + // Associate new random account with user. + err = mockUserAccount(user.ID, accountId, user.CreatedAt, auth.RoleUser) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tCreate user account failed.", tests.Failed) + } + + // Mock the methods needed to make a password reset. + resetUrl := func(string) string { + return "" + } + notify := ¬ify.MockEmail{} + + secretKey := "6368616e676520746869732070617373" + + // Ensure validation is working by trying ResetPassword with an empty request. + { + expectedErr := errors.New("Key: 'UserResetPasswordRequest.email' Error:Field validation for 'email' failed on the 'required' tag") + _, err = ResetPassword(ctx, test.MasterDB, resetUrl, notify, UserResetPasswordRequest{}, secretKey, now) + if err == nil { + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tResetPassword failed.", tests.Failed) + } + + errStr := strings.Replace(err.Error(), "{{", "", -1) + errStr = strings.Replace(errStr, "}}", "", -1) + + if errStr != expectedErr.Error() { + t.Logf("\t\tGot : %+v", errStr) + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tResetPassword Validation failed.", tests.Failed) + } + t.Logf("\t%s\tResetPassword Validation ok.", tests.Success) + } + + ttl := time.Hour + + // Make the reset password request. + resetHash, err := ResetPassword(ctx, test.MasterDB, resetUrl, notify, UserResetPasswordRequest{ + Email: user.Email, + TTL: ttl, + }, secretKey, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tResetPassword failed.", tests.Failed) + } + t.Logf("\t%s\tResetPassword ok.", tests.Success) + + // Read the user to ensure the password_reset field was set. + user, err = Read(ctx, auth.Claims{}, test.MasterDB, user.ID, false) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tRead failed.", tests.Failed) + } else if user.PasswordReset == nil || user.PasswordReset.String == "" { + t.Fatalf("\t%s\tUser field password_reset is empty.", tests.Failed) + } + + // Ensure validation is working by trying ResetConfirm with an empty request. + { + expectedErr := errors.New("Key: 'UserResetConfirmRequest.reset_hash' Error:Field validation for 'reset_hash' failed on the 'required' tag\n" + + "Key: 'UserResetConfirmRequest.password' Error:Field validation for 'password' failed on the 'required' tag\n" + + "Key: 'UserResetConfirmRequest.password_confirm' Error:Field validation for 'password_confirm' failed on the 'required' tag") + _, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{}, secretKey, now) + if err == nil { + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) + } + + errStr := strings.Replace(err.Error(), "{{", "", -1) + errStr = strings.Replace(errStr, "}}", "", -1) + + if errStr != expectedErr.Error() { + t.Logf("\t\tGot : %+v", errStr) + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tResetConfirm Validation failed.", tests.Failed) + } + t.Logf("\t%s\tResetConfirm Validation ok.", tests.Success) + } + + // Ensure the TTL is enforced. + { + newPass := uuid.NewRandom().String() + _, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{ + ResetHash: resetHash, + Password: newPass, + PasswordConfirm: newPass, + }, secretKey, now.UTC().Add(ttl*2)) + if errors.Cause(err) != ErrResetExpired { + t.Logf("\t\tGot : %+v", errors.Cause(err)) + t.Logf("\t\tWant: %+v", ErrResetExpired) + t.Fatalf("\t%s\tResetConfirm enforce TTL failed.", tests.Failed) + } + t.Logf("\t%s\tResetConfirm enforce TTL ok.", tests.Success) + } + + // Assuming we have received the email and clicked the link, we now can ensure confirm works. + newPass := uuid.NewRandom().String() + reset, err := ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{ + ResetHash: resetHash, + Password: newPass, + PasswordConfirm: newPass, + }, secretKey, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) + } else if reset.ID != user.ID { + t.Logf("\t\tGot : %+v", reset.ID) + t.Logf("\t\tWant: %+v", user.ID) + t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) + } + t.Logf("\t%s\tResetConfirm ok.", tests.Success) + + // Verify that the user can be authenticated with the updated password. + _, err = Authenticate(ctx, test.MasterDB, tknGen, user.Email, newPass, time.Hour, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tAuthenticate failed.", tests.Failed) + } + t.Logf("\t%s\tAuthenticate ok.", tests.Success) + + // Ensure the reset hash does not work after its used. + { + newPass := uuid.NewRandom().String() + _, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{ + ResetHash: resetHash, + Password: newPass, + PasswordConfirm: newPass, + }, secretKey, now) + if errors.Cause(err) != ErrNotFound { + t.Logf("\t\tGot : %+v", errors.Cause(err)) + t.Logf("\t\tWant: %+v", ErrNotFound) + t.Fatalf("\t%s\tResetConfirm enforce TTL failed.", tests.Failed) + } + t.Logf("\t%s\tResetConfirm reuse disabled ok.", tests.Success) + } + } +} + func mockUserAccount(userId, accountId string, now time.Time, roles ...string) error { var roleArr pq.StringArray for _, r := range roles { diff --git a/internal/user_account/invite/invite.go b/internal/user_account/invite/invite.go new file mode 100644 index 0000000..1257a69 --- /dev/null +++ b/internal/user_account/invite/invite.go @@ -0,0 +1,271 @@ +package invite + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + "github.com/sudo-suhas/symcrypto" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) + +var ( + // ErrInviteExpired occurs when the the reset hash exceeds the expiration. + ErrInviteExpired = errors.New("Invite expired") + + // ErrInviteUserPasswordSet occurs when the the reset hash exceeds the expiration. + ErrInviteUserPasswordSet = errors.New("User password set") +) + +// InviteUsers sends emails to the users inviting them to join an account. +func InviteUsers(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, resetUrl func(string) string, notify notify.Email, req InviteUsersRequest, secretKey string, now time.Time) ([]string, error) { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.InviteUsers") + defer span.Finish() + + v := webcontext.Validator() + + // Validate the request. + err := v.StructCtx(ctx, req) + if err != nil { + return nil, err + } + + // Ensure the claims can modify the account specified in the request. + err = user_account.CanModifyAccount(ctx, claims, dbConn, req.AccountID) + if err != nil { + return nil, err + } + + // Find all the users by email address. + emailUserIDs := make(map[string]string) + { + // Find all users without passing in claims to search all users. + where := fmt.Sprintf("email in ('%s')", strings.Join(req.Emails, "','")) + users, err := user.Find(ctx, auth.Claims{}, dbConn, user.UserFindRequest{ + Where: &where, + }) + if err != nil { + return nil, err + } + + for _, u := range users { + emailUserIDs[u.Email] = u.ID + } + } + + // Find users that are already active for this account. + activelUserIDs := make(map[string]bool) + { + var args []string + for _, userID := range emailUserIDs { + args = append(args, userID) + } + + where := fmt.Sprintf("user_id in ('%s') and status = '%s'", strings.Join(args, "','"), user_account.UserAccountStatus_Active.String()) + userAccs, err := user_account.Find(ctx, claims, dbConn, user_account.UserAccountFindRequest{ + Where: &where, + }) + if err != nil { + return nil, err + } + + for _, userAcc := range userAccs { + activelUserIDs[userAcc.UserID] = true + } + } + + // Always store the time as UTC. + now = now.UTC() + + // Postgres truncates times to milliseconds when storing. We and do the same + // here so the value we return is consistent with what we store. + now = now.Truncate(time.Millisecond) + + // Create any users that don't already exist. + for _, email := range req.Emails { + if uId, ok := emailUserIDs[email]; ok && uId != "" { + continue + } + + u, err := user.CreateInvite(ctx, claims, dbConn, user.UserCreateInviteRequest{ + Email: email, + }, now) + if err != nil { + return nil, err + } + + emailUserIDs[email] = u.ID + } + + // Loop through all the existing users who either do not have an user_account record or + // have an existing record, but the status is disabled. + for _, userID := range emailUserIDs { + // User already is active, skip. + if activelUserIDs[userID] { + continue + } + + status := user_account.UserAccountStatus_Invited + _, err = user_account.Create(ctx, claims, dbConn, user_account.UserAccountCreateRequest{ + UserID: userID, + AccountID: req.AccountID, + Roles: req.Roles, + Status: &status, + }, now) + if err != nil { + return nil, err + } + } + + if req.TTL.Seconds() == 0 { + req.TTL = time.Minute * 90 + } + + fromUser, err := user.Read(ctx, claims, dbConn, req.UserID, false) + if err != nil { + return nil, err + } + + account, err := account.Read(ctx, claims, dbConn, req.AccountID, false) + if err != nil { + return nil, err + } + + // Load the current IP makings the request. + var requestIp string + if vals, _ := webcontext.ContextValues(ctx); vals != nil { + requestIp = vals.RequestIP + } + + var inviteHashes []string + for email, userID := range emailUserIDs { + + // Generate a string that embeds additional information. + hashPts := []string{ + userID, + strconv.Itoa(int(now.UTC().Unix())), + strconv.Itoa(int(now.UTC().Add(req.TTL).Unix())), + requestIp, + } + hashStr := strings.Join(hashPts, "|") + + // This returns the nonce appended with the encrypted string. + crypto, err := symcrypto.New(secretKey) + if err != nil { + return nil, errors.WithStack(err) + } + encrypted, err := crypto.Encrypt(hashStr) + if err != nil { + return nil, errors.WithStack(err) + } + + data := map[string]interface{}{ + "FromUser": fromUser.Response(ctx), + "Account": account.Response(ctx), + "Url": resetUrl(encrypted), + "Minutes": req.TTL.Minutes(), + } + + subject := fmt.Sprintf("%s %s has invited you to %s", fromUser.FirstName, fromUser.LastName, account.Name) + + err = notify.Send(ctx, email, subject, "user_invite", data) + if err != nil { + err = errors.WithMessagef(err, "Send invite to %s failed.", email) + return nil, err + } + + inviteHashes = append(inviteHashes, encrypted) + } + + return inviteHashes, nil +} + +// InviteAccept updates the password for a user using the provided reset password ID. +func InviteAccept(ctx context.Context, dbConn *sqlx.DB, req InviteAcceptRequest, secretKey string, now time.Time) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.user_account.invite.InviteAccept") + defer span.Finish() + + v := webcontext.Validator() + + // Validate the request. + err := v.StructCtx(ctx, req) + if err != nil { + return err + } + + crypto, err := symcrypto.New(secretKey) + if err != nil { + return errors.WithStack(err) + } + hashStr, err := crypto.Decrypt(req.InviteHash) + if err != nil { + return errors.WithStack(err) + } + hashPts := strings.Split(hashStr, "|") + + var hash InviteHash + if len(hashPts) == 4 { + hash.UserID = hashPts[0] + hash.CreatedAt, _ = strconv.Atoi(hashPts[1]) + hash.ExpiresAt, _ = strconv.Atoi(hashPts[2]) + hash.RequestIP = hashPts[3] + } + + // Validate the hash. + err = v.StructCtx(ctx, hash) + if err != nil { + return err + } + + if int64(hash.ExpiresAt) < now.UTC().Unix() { + err = errors.WithMessage(ErrInviteExpired, "Invite has expired.") + return err + } + + u, err := user.Read(ctx, auth.Claims{}, dbConn, hash.UserID, true) + if err != nil { + return err + } + + if u.ArchivedAt != nil && !u.ArchivedAt.Time.IsZero() { + err = user.Unarchive(ctx, auth.Claims{}, dbConn, user.UserUnarchiveRequest{ID: hash.UserID}, now) + if err != nil { + return err + } + } else if len(u.PasswordHash) > 0 { + // Do not update the password for a user that already has a password set. + err = errors.WithMessage(ErrInviteUserPasswordSet, "Invite user already has a password set.") + return err + } + + err = user.Update(ctx, auth.Claims{}, dbConn, user.UserUpdateRequest{ + ID: hash.UserID, + FirstName: &req.FirstName, + LastName: &req.LastName, + Timezone: req.Timezone, + }, now) + if err != nil { + return err + } + + err = user.UpdatePassword(ctx, auth.Claims{}, dbConn, user.UserUpdatePasswordRequest{ + ID: hash.UserID, + Password: req.Password, + PasswordConfirm: req.PasswordConfirm, + }, now) + if err != nil { + return err + } + + return nil +} diff --git a/internal/user_account/invite/invite_test.go b/internal/user_account/invite/invite_test.go new file mode 100644 index 0000000..1be4561 --- /dev/null +++ b/internal/user_account/invite/invite_test.go @@ -0,0 +1,266 @@ +package invite + +import ( + "os" + "strings" + "testing" + "time" + + "geeks-accelerator/oss/saas-starter-kit/internal/account" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/notify" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/tests" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" + "github.com/dgrijalva/jwt-go" + "github.com/huandu/go-sqlbuilder" + "github.com/pborman/uuid" + "github.com/pkg/errors" +) + +var test *tests.Test + +// TestMain is the entry point for testing. +func TestMain(m *testing.M) { + os.Exit(testMain(m)) +} + +func testMain(m *testing.M) int { + test = tests.New() + defer test.TearDown() + return m.Run() +} + +// TestInviteUsers validates that invite users works. +func TestInviteUsers(t *testing.T) { + + t.Log("Given the need ensure a user an invite users to their account.") + { + ctx := tests.Context() + + now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC) + + // Create a new user for testing. + initPass := uuid.NewRandom().String() + u, err := user.Create(ctx, auth.Claims{}, test.MasterDB, user.UserCreateRequest{ + FirstName: "Lee", + LastName: "Brown", + Email: uuid.NewRandom().String() + "@geeksinthewoods.com", + Password: initPass, + PasswordConfirm: initPass, + }, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tCreate user failed.", tests.Failed) + } + + a, err := account.Create(ctx, auth.Claims{}, test.MasterDB, account.AccountCreateRequest{ + Name: uuid.NewRandom().String(), + Address1: "101 E Main", + City: "Valdez", + Region: "AK", + Country: "US", + Zipcode: "99686", + }, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tCreate account failed.", tests.Failed) + } + + uRoles := []user_account.UserAccountRole{user_account.UserAccountRole_Admin} + _, err = user_account.Create(ctx, auth.Claims{}, test.MasterDB, user_account.UserAccountCreateRequest{ + UserID: u.ID, + AccountID: a.ID, + Roles: uRoles, + }, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tCreate account failed.", tests.Failed) + } + + claims := auth.Claims{ + AccountIds: []string{a.ID}, + StandardClaims: jwt.StandardClaims{ + Subject: u.ID, + Audience: a.ID, + IssuedAt: now.Unix(), + ExpiresAt: now.Add(time.Hour).Unix(), + }, + } + for _, r := range uRoles { + claims.Roles = append(claims.Roles, r.String()) + } + + // Mock the methods needed to make a password reset. + resetUrl := func(string) string { + return "" + } + notify := ¬ify.MockEmail{} + + secretKey := "6368616e676520746869732070617373" + + // Ensure validation is working by trying ResetPassword with an empty request. + { + expectedErr := errors.New("Key: 'InviteUsersRequest.account_id' Error:Field validation for 'account_id' failed on the 'required' tag\n" + + "Key: 'InviteUsersRequest.user_id' Error:Field validation for 'user_id' failed on the 'required' tag\n" + + "Key: 'InviteUsersRequest.emails' Error:Field validation for 'emails' failed on the 'required' tag\n" + + "Key: 'InviteUsersRequest.roles' Error:Field validation for 'roles' failed on the 'required' tag") + _, err = InviteUsers(ctx, claims, test.MasterDB, resetUrl, notify, InviteUsersRequest{}, secretKey, now) + if err == nil { + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tInviteUsers failed.", tests.Failed) + } + + errStr := strings.Replace(err.Error(), "{{", "", -1) + errStr = strings.Replace(errStr, "}}", "", -1) + + if errStr != expectedErr.Error() { + t.Logf("\t\tGot : %+v", errStr) + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tInviteUsers Validation failed.", tests.Failed) + } + t.Logf("\t%s\tInviteUsers Validation ok.", tests.Success) + } + + ttl := time.Hour + + inviteEmails := []string{ + uuid.NewRandom().String() + "@geeksinthewoods.com", + } + + // Make the reset password request. + inviteHashes, err := InviteUsers(ctx, claims, test.MasterDB, resetUrl, notify, InviteUsersRequest{ + UserID: u.ID, + AccountID: a.ID, + Emails: inviteEmails, + Roles: []user_account.UserAccountRole{user_account.UserAccountRole_User}, + TTL: ttl, + }, secretKey, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tInviteUsers failed.", tests.Failed) + } else if len(inviteHashes) != len(inviteEmails) { + t.Logf("\t\tGot : %+v", len(inviteHashes)) + t.Logf("\t\tWant: %+v", len(inviteEmails)) + t.Fatalf("\t%s\tInviteUsers failed.", tests.Failed) + } + t.Logf("\t%s\tInviteUsers ok.", tests.Success) + + // Ensure validation is working by trying ResetConfirm with an empty request. + { + expectedErr := errors.New("Key: 'InviteAcceptRequest.invite_hash' Error:Field validation for 'invite_hash' failed on the 'required' tag\n" + + "Key: 'InviteAcceptRequest.first_name' Error:Field validation for 'first_name' failed on the 'required' tag\n" + + "Key: 'InviteAcceptRequest.last_name' Error:Field validation for 'last_name' failed on the 'required' tag\n" + + "Key: 'InviteAcceptRequest.password' Error:Field validation for 'password' failed on the 'required' tag\n" + + "Key: 'InviteAcceptRequest.password_confirm' Error:Field validation for 'password_confirm' failed on the 'required' tag") + err = InviteAccept(ctx, test.MasterDB, InviteAcceptRequest{}, secretKey, now) + if err == nil { + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed) + } + + errStr := strings.Replace(err.Error(), "{{", "", -1) + errStr = strings.Replace(errStr, "}}", "", -1) + + if errStr != expectedErr.Error() { + t.Logf("\t\tGot : %+v", errStr) + t.Logf("\t\tWant: %+v", expectedErr) + t.Fatalf("\t%s\tResetConfirm Validation failed.", tests.Failed) + } + t.Logf("\t%s\tResetConfirm Validation ok.", tests.Success) + } + + // Ensure the TTL is enforced. + { + newPass := uuid.NewRandom().String() + err = InviteAccept(ctx, test.MasterDB, InviteAcceptRequest{ + InviteHash: inviteHashes[0], + FirstName: "Foo", + LastName: "Bar", + Password: newPass, + PasswordConfirm: newPass, + }, secretKey, now.UTC().Add(ttl*2)) + if errors.Cause(err) != ErrInviteExpired { + t.Logf("\t\tGot : %+v", errors.Cause(err)) + t.Logf("\t\tWant: %+v", ErrInviteExpired) + t.Fatalf("\t%s\tInviteAccept enforce TTL failed.", tests.Failed) + } + t.Logf("\t%s\tInviteAccept enforce TTL ok.", tests.Success) + } + + // Assuming we have received the email and clicked the link, we now can ensure accept works. + for _, inviteHash := range inviteHashes { + newPass := uuid.NewRandom().String() + err = InviteAccept(ctx, test.MasterDB, InviteAcceptRequest{ + InviteHash: inviteHash, + FirstName: "Foo", + LastName: "Bar", + Password: newPass, + PasswordConfirm: newPass, + }, secretKey, now) + if err != nil { + t.Log("\t\tGot :", err) + t.Fatalf("\t%s\tInviteAccept failed.", tests.Failed) + } + t.Logf("\t%s\tInviteAccept ok.", tests.Success) + } + + // Ensure the reset hash does not work after its used. + { + newPass := uuid.NewRandom().String() + err = InviteAccept(ctx, test.MasterDB, InviteAcceptRequest{ + InviteHash: inviteHashes[0], + FirstName: "Foo", + LastName: "Bar", + Password: newPass, + PasswordConfirm: newPass, + }, secretKey, now) + if errors.Cause(err) != ErrInviteUserPasswordSet { + t.Logf("\t\tGot : %+v", errors.Cause(err)) + t.Logf("\t\tWant: %+v", ErrInviteUserPasswordSet) + t.Fatalf("\t%s\tInviteAccept verify reuse failed.", tests.Failed) + } + t.Logf("\t%s\tInviteAccept verify reuse disabled ok.", tests.Success) + } + } +} + +func mockAccount(accountId string, now time.Time) error { + + // Build the insert SQL statement. + query := sqlbuilder.NewInsertBuilder() + query.InsertInto("accounts") + query.Cols("id", "name", "created_at", "updated_at") + query.Values(accountId, uuid.NewRandom().String(), now, now) + + // Execute the query with the provided context. + sql, args := query.Build() + sql = test.MasterDB.Rebind(sql) + _, err := test.MasterDB.ExecContext(tests.Context(), sql, args...) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + return err + } + + return nil +} + +func mockUser(userId string, now time.Time) error { + + // Build the insert SQL statement. + query := sqlbuilder.NewInsertBuilder() + query.InsertInto("users") + query.Cols("id", "email", "password_hash", "password_salt", "created_at", "updated_at") + query.Values(userId, uuid.NewRandom().String(), "-", "-", now, now) + + // Execute the query with the provided context. + sql, args := query.Build() + sql = test.MasterDB.Rebind(sql) + _, err := test.MasterDB.ExecContext(tests.Context(), sql, args...) + if err != nil { + err = errors.Wrapf(err, "query - %s", query.String()) + return err + } + + return nil +} diff --git a/internal/user_account/invite/models.go b/internal/user_account/invite/models.go new file mode 100644 index 0000000..28fa9b5 --- /dev/null +++ b/internal/user_account/invite/models.go @@ -0,0 +1,34 @@ +package invite + +import ( + "time" + + "geeks-accelerator/oss/saas-starter-kit/internal/user_account" +) + +// InviteUsersRequest defines the data needed to make an invite request. +type InviteUsersRequest struct { + AccountID string `json:"account_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + UserID string `json:"user_id" validate:"required,uuid" example:"c4653bf9-5978-48b7-89c5-95704aebb7e2"` + Emails []string `json:"emails" validate:"required,dive,email"` + Roles []user_account.UserAccountRole `json:"roles" validate:"required"` + TTL time.Duration `json:"ttl,omitempty" ` +} + +// InviteHash +type InviteHash struct { + UserID string `json:"user_id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + CreatedAt int `json:"created_at" validate:"required"` + ExpiresAt int `json:"expires_at" validate:"required"` + RequestIP string `json:"request_ip" validate:"required,ip" example:"69.56.104.36"` +} + +// InviteAcceptRequest defines the fields need to complete an invite request. +type InviteAcceptRequest struct { + InviteHash string `json:"invite_hash" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"` + FirstName string `json:"first_name" validate:"required" example:"Gabi"` + LastName string `json:"last_name" validate:"required" example:"May"` + Password string `json:"password" validate:"required" example:"SecretString"` + PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"` + Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"` +} diff --git a/internal/user_account/user_account.go b/internal/user_account/user_account.go index c2b20d1..c1e72d5 100644 --- a/internal/user_account/user_account.go +++ b/internal/user_account/user_account.go @@ -3,11 +3,11 @@ package user_account import ( "context" "database/sql" - "geeks-accelerator/oss/saas-starter-kit/internal/account" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "time" + "geeks-accelerator/oss/saas-starter-kit/internal/account" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/pborman/uuid" @@ -26,6 +26,9 @@ var ( // The database table for UserAccount const userAccountTableName = "users_accounts" +// The database table for User +const userTableName = "users" + // The list of columns needed for mapRowsToUserAccount var userAccountMapColumns = "id,user_id,account_id,roles,status,created_at,updated_at,archived_at" diff --git a/resources/templates/shared/emails/user_invite.html b/resources/templates/shared/emails/user_invite.html new file mode 100644 index 0000000..e8bdd0b --- /dev/null +++ b/resources/templates/shared/emails/user_invite.html @@ -0,0 +1,19 @@ + + +
+
+

{{ .FromUser.FirstName }} has invited you to join {{ .Account.Name }}.

+

To accept the invite, follow this link (or paste into your browser) within the next {{ .Minutes }} minutes.

+

{{ .Url }}

+

 
- Support

+
+
\ No newline at end of file diff --git a/resources/templates/shared/emails/user_invite.txt b/resources/templates/shared/emails/user_invite.txt new file mode 100644 index 0000000..49d7a42 --- /dev/null +++ b/resources/templates/shared/emails/user_invite.txt @@ -0,0 +1,4 @@ +{{ .FromUser.FirstName }} has invited you to join {{ .Account.Name }}. + +To accept the invite, follow this link (or paste into your browser) within the next {{ .Minutes }} minutes. +{{ .Url }} \ No newline at end of file diff --git a/resources/templates/shared/emails/user_reset_password.html b/resources/templates/shared/emails/user_reset_password.html new file mode 100644 index 0000000..70e8b7d --- /dev/null +++ b/resources/templates/shared/emails/user_reset_password.html @@ -0,0 +1,20 @@ + + +
+
+

{{ .Name }},

+

Someone in space has asked to reset the password for your account. If you did not request a password reset, you can disregard this email. No changes have been made to your account.

+

To reset your password, follow this link (or paste into your browser) within the next {{ .Minutes }} minutes.

+

{{ .Url }}

+

 
- Support

+
+
\ No newline at end of file diff --git a/resources/templates/shared/emails/user_reset_password.txt b/resources/templates/shared/emails/user_reset_password.txt new file mode 100644 index 0000000..893c869 --- /dev/null +++ b/resources/templates/shared/emails/user_reset_password.txt @@ -0,0 +1,4 @@ +{{ .Name }}, Someone in space has asked to reset the password for your account. If you did not request a password reset, you can disregard this email. No changes have been made to your account. + +To reset your password, follow this link (or paste into your browser) within the next {{ .Minutes }} minutes. +{{ .Url }} \ No newline at end of file diff --git a/tools/devops/cmd/cicd/service_deploy.go b/tools/devops/cmd/cicd/service_deploy.go index a1ea938..b314b35 100644 --- a/tools/devops/cmd/cicd/service_deploy.go +++ b/tools/devops/cmd/cicd/service_deploy.go @@ -514,6 +514,7 @@ func NewServiceDeployRequest(log *log.Logger, flags ServiceDeployFlags) (*servic "route53:ChangeResourceRecordSets", "ecs:UpdateService", "ses:SendEmail", + "ses:ListIdentities", "secretsmanager:ListSecretVersionIds", "secretsmanager:GetSecretValue", "secretsmanager:CreateSecret",