You've already forked golang-saas-starter-kit
mirror of
https://github.com/raseels-repos/golang-saas-starter-kit.git
synced 2025-08-08 22:36:41 +02:00
Completed implimentation of forgot password
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
aws.lee
|
aws.lee
|
||||||
aws.*
|
aws.*
|
||||||
.env_docker_compose
|
.env_docker_compose
|
||||||
|
local.env
|
||||||
|
@ -92,6 +92,7 @@ webapp:deploy:dev:
|
|||||||
STATIC_FILES_S3: 'true'
|
STATIC_FILES_S3: 'true'
|
||||||
STATIC_FILES_IMG_RESIZE: 'true'
|
STATIC_FILES_IMG_RESIZE: 'true'
|
||||||
AWS_USE_ROLE: 'true'
|
AWS_USE_ROLE: 'true'
|
||||||
|
EMAIL_SENDER: 'lee@geeksinthewoods.com'
|
||||||
|
|
||||||
webapi:build:dev:
|
webapi:build:dev:
|
||||||
<<: *build_tmpl
|
<<: *build_tmpl
|
||||||
@ -131,6 +132,7 @@ webapi:deploy:dev:
|
|||||||
STATIC_FILES_S3: 'false'
|
STATIC_FILES_S3: 'false'
|
||||||
STATIC_FILES_IMG_RESIZE: 'false'
|
STATIC_FILES_IMG_RESIZE: 'false'
|
||||||
AWS_USE_ROLE: 'true'
|
AWS_USE_ROLE: 'true'
|
||||||
|
EMAIL_SENDER: 'lee@geeksinthewoods.com'
|
||||||
|
|
||||||
#ddlogscollector:deploy:stage:
|
#ddlogscollector:deploy:stage:
|
||||||
# <<: *deploy_stage_tmpl
|
# <<: *deploy_stage_tmpl
|
||||||
|
@ -381,7 +381,7 @@ schema migrations before running any unit tests.
|
|||||||
To login to the local Postgres container, use the following command:
|
To login to the local Postgres container, use the following command:
|
||||||
```bash
|
```bash
|
||||||
docker exec -it saas-starter-kit_postgres_1 /bin/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
|
The example project currently only includes a few tables. As more functionality is built into both the web-app and
|
||||||
|
@ -37,6 +37,9 @@ COPY cmd/web-api ./cmd/web-api
|
|||||||
COPY cmd/web-api/templates /templates
|
COPY cmd/web-api/templates /templates
|
||||||
#COPY cmd/web-api/static /static
|
#COPY cmd/web-api/static /static
|
||||||
|
|
||||||
|
# Copy the global templates.
|
||||||
|
ADD resources/templates/shared /templates/shared
|
||||||
|
|
||||||
WORKDIR ./cmd/web-api
|
WORKDIR ./cmd/web-api
|
||||||
|
|
||||||
# Update the API documentation.
|
# Update the API documentation.
|
||||||
@ -54,6 +57,8 @@ COPY --from=builder /gosrv /
|
|||||||
COPY --from=builder /templates /templates
|
COPY --from=builder /templates /templates
|
||||||
|
|
||||||
ENV TEMPLATE_DIR=/templates
|
ENV TEMPLATE_DIR=/templates
|
||||||
|
ENV SHARED_TEMPLATE_DIR=/templates/shared
|
||||||
|
#ENV STATIC_DIR=/static
|
||||||
|
|
||||||
ARG service
|
ARG service
|
||||||
ENV SERVICE_NAME $service
|
ENV SERVICE_NAME $service
|
||||||
|
@ -38,6 +38,7 @@
|
|||||||
{"name": "WEB_API_SERVICE_BASE_URL", "value": "{APP_BASE_URL}"},
|
{"name": "WEB_API_SERVICE_BASE_URL", "value": "{APP_BASE_URL}"},
|
||||||
{"name": "WEB_API_SERVICE_HOST_NAMES", "value": "{HOST_NAMES}"},
|
{"name": "WEB_API_SERVICE_HOST_NAMES", "value": "{HOST_NAMES}"},
|
||||||
{"name": "WEB_API_SERVICE_ENABLE_HTTPS", "value": "{HTTPS_ENABLED}"},
|
{"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_REDIS_HOST", "value": "{CACHE_HOST}"},
|
||||||
{"name": "WEB_API_DB_HOST", "value": "{DB_HOST}"},
|
{"name": "WEB_API_DB_HOST", "value": "{DB_HOST}"},
|
||||||
{"name": "WEB_API_DB_USER", "value": "{DB_USER}"},
|
{"name": "WEB_API_DB_USER", "value": "{DB_USER}"},
|
||||||
|
@ -2,3 +2,4 @@ export WEB_API_DB_HOST=127.0.0.1:5433
|
|||||||
export WEB_API_DB_USER=postgres
|
export WEB_API_DB_USER=postgres
|
||||||
export WEB_API_DB_PASS=postgres
|
export WEB_API_DB_PASS=postgres
|
||||||
export WEB_API_DB_DISABLE_TLS=true
|
export WEB_API_DB_DISABLE_TLS=true
|
||||||
|
export WEB_API_SERVICE_EMAIL_SENDER=valdez@example.com
|
||||||
|
@ -24,6 +24,9 @@ COPY cmd/web-app ./cmd/web-app
|
|||||||
COPY cmd/web-app/templates /templates
|
COPY cmd/web-app/templates /templates
|
||||||
COPY cmd/web-app/static /static
|
COPY cmd/web-app/static /static
|
||||||
|
|
||||||
|
# Copy the global templates.
|
||||||
|
ADD resources/templates/shared /templates/shared
|
||||||
|
|
||||||
WORKDIR ./cmd/web-app
|
WORKDIR ./cmd/web-app
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /gosrv .
|
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
|
COPY --from=builder /templates /templates
|
||||||
|
|
||||||
ENV TEMPLATE_DIR=/templates
|
ENV TEMPLATE_DIR=/templates
|
||||||
|
ENV SHARED_TEMPLATE_DIR=/templates/shared
|
||||||
ENV STATIC_DIR=/static
|
ENV STATIC_DIR=/static
|
||||||
|
|
||||||
ARG service
|
ARG service
|
||||||
|
@ -42,6 +42,7 @@
|
|||||||
{"name": "WEB_APP_SERVICE_STATICFILES_S3_PREFIX", "value": "{STATIC_FILES_S3_PREFIX}"},
|
{"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_CLOUDFRONT_ENABLED", "value": "{STATIC_FILES_CLOUDFRONT_ENABLED}"},
|
||||||
{"name": "WEB_APP_SERVICE_STATICFILES_IMG_RESIZE_ENABLED", "value": "{STATIC_FILES_IMG_RESIZE_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_REDIS_HOST", "value": "{CACHE_HOST}"},
|
||||||
{"name": "WEB_APP_DB_HOST", "value": "{DB_HOST}"},
|
{"name": "WEB_APP_DB_HOST", "value": "{DB_HOST}"},
|
||||||
{"name": "WEB_APP_DB_USER", "value": "{DB_USER}"},
|
{"name": "WEB_APP_DB_USER", "value": "{DB_USER}"},
|
||||||
|
@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -23,7 +24,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// API returns a handler for a set of routes.
|
// 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.
|
// Define base middlewares applied to all requests.
|
||||||
middlewares := []web.Middleware{
|
middlewares := []web.Middleware{
|
||||||
@ -50,13 +51,18 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
|
|||||||
MasterDB: masterDB,
|
MasterDB: masterDB,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
Authenticator: authenticator,
|
Authenticator: authenticator,
|
||||||
|
ProjectRoutes: projectRoutes,
|
||||||
|
NotifyEmail: notifyEmail,
|
||||||
|
SecretKey: secretKey,
|
||||||
}
|
}
|
||||||
// This route is not authenticated
|
// This route is not authenticated
|
||||||
app.Handle("POST", "/user/login", u.Login)
|
app.Handle("POST", "/user/login", u.Login)
|
||||||
app.Handle("GET", "/user/login", u.Login)
|
app.Handle("GET", "/user/login", u.Login)
|
||||||
app.Handle("GET", "/user/logout", u.Logout)
|
app.Handle("GET", "/user/logout", u.Logout)
|
||||||
app.Handle("POST", "/user/forgot-password", u.ForgotPassword)
|
app.Handle("POST", "/user/reset-password/:hash", u.ResetConfirm)
|
||||||
app.Handle("GET", "/user/forgot-password", u.ForgotPassword)
|
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.
|
// Register user management and authentication endpoints.
|
||||||
s := Signup{
|
s := Signup{
|
||||||
|
@ -2,6 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||||
|
project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -23,6 +25,9 @@ type User struct {
|
|||||||
MasterDB *sqlx.DB
|
MasterDB *sqlx.DB
|
||||||
Renderer web.Renderer
|
Renderer web.Renderer
|
||||||
Authenticator *auth.Authenticator
|
Authenticator *auth.Authenticator
|
||||||
|
ProjectRoutes project_routes.ProjectRoutes
|
||||||
|
NotifyEmail notify.Email
|
||||||
|
SecretKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserLoginRequest struct {
|
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.
|
// 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)
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"expvar"
|
"expvar"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||||
|
"gopkg.in/gomail.v2"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
@ -87,6 +89,7 @@ func main() {
|
|||||||
HostNames []string `envconfig:"HOST_NAMES" example:"www.eproc.tech"`
|
HostNames []string `envconfig:"HOST_NAMES" example:"www.eproc.tech"`
|
||||||
EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"`
|
EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"`
|
||||||
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
|
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
|
||||||
|
SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"`
|
||||||
StaticFiles struct {
|
StaticFiles struct {
|
||||||
Dir string `default:"./static" envconfig:"STATIC_DIR"`
|
Dir string `default:"./static" envconfig:"STATIC_DIR"`
|
||||||
S3Enabled bool `envconfig:"S3_ENABLED"`
|
S3Enabled bool `envconfig:"S3_ENABLED"`
|
||||||
@ -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"`
|
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"`
|
SessionKey string `default:"" envconfig:"SESSION_KEY"`
|
||||||
SessionName string `default:"" envconfig:"SESSION_NAME"`
|
SessionName string `default:"" envconfig:"SESSION_NAME"`
|
||||||
|
EmailSender string `default:"" envconfig:"EMAIL_SENDER"`
|
||||||
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
|
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
|
||||||
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
|
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
|
||||||
}
|
}
|
||||||
@ -137,6 +141,12 @@ func main() {
|
|||||||
UseAwsSecretManager bool `default:"false" envconfig:"USE_AWS_SECRET_MANAGER"`
|
UseAwsSecretManager bool `default:"false" envconfig:"USE_AWS_SECRET_MANAGER"`
|
||||||
KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"`
|
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 {
|
BuildInfo struct {
|
||||||
CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"`
|
CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"`
|
||||||
CiCommitRefSlug string `envconfig:"CI_COMMIT_REF_SLUG"`
|
CiCommitRefSlug string `envconfig:"CI_COMMIT_REF_SLUG"`
|
||||||
@ -351,6 +361,38 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer masterDb.Close()
|
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
|
// Init new Authenticator
|
||||||
var authenticator *auth.Authenticator
|
var authenticator *auth.Authenticator
|
||||||
@ -776,7 +818,7 @@ func main() {
|
|||||||
if cfg.HTTP.Host != "" {
|
if cfg.HTTP.Host != "" {
|
||||||
api := http.Server{
|
api := http.Server{
|
||||||
Addr: cfg.HTTP.Host,
|
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,
|
ReadTimeout: cfg.HTTP.ReadTimeout,
|
||||||
WriteTimeout: cfg.HTTP.WriteTimeout,
|
WriteTimeout: cfg.HTTP.WriteTimeout,
|
||||||
MaxHeaderBytes: 1 << 20,
|
MaxHeaderBytes: 1 << 20,
|
||||||
@ -793,7 +835,7 @@ func main() {
|
|||||||
if cfg.HTTPS.Host != "" {
|
if cfg.HTTPS.Host != "" {
|
||||||
api := http.Server{
|
api := http.Server{
|
||||||
Addr: cfg.HTTPS.Host,
|
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,
|
ReadTimeout: cfg.HTTPS.ReadTimeout,
|
||||||
WriteTimeout: cfg.HTTPS.WriteTimeout,
|
WriteTimeout: cfg.HTTPS.WriteTimeout,
|
||||||
MaxHeaderBytes: 1 << 20,
|
MaxHeaderBytes: 1 << 20,
|
||||||
|
@ -2,3 +2,4 @@ export WEB_APP_DB_HOST=127.0.0.1:5433
|
|||||||
export WEB_APP_DB_USER=postgres
|
export WEB_APP_DB_USER=postgres
|
||||||
export WEB_APP_DB_PASS=postgres
|
export WEB_APP_DB_PASS=postgres
|
||||||
export WEB_APP_DB_DISABLE_TLS=true
|
export WEB_APP_DB_DISABLE_TLS=true
|
||||||
|
export WEB_APP_SERVICE_EMAIL_SENDER=valdez@example.com
|
@ -1 +1,25 @@
|
|||||||
console.log("test");
|
|
||||||
|
$(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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{ define "partials/page-wrapper" }}
|
{{ define "partials/page-wrapper" }}
|
||||||
<div class="container">
|
<div class="container" id="page-content">
|
||||||
|
|
||||||
<div class="card o-hidden border-0 shadow-lg my-5">
|
<div class="card o-hidden border-0 shadow-lg my-5">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
@ -102,7 +102,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a class="small" href="/user/forgot-password">Forgot Password?</a>
|
<a class="small" href="/user/reset-password">Forgot Password?</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a class="small" href="/user/login">Already have an account? Login!</a>
|
<a class="small" href="/user/login">Already have an account? Login!</a>
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
{{ define "partials/page-wrapper" }}
|
{{ define "partials/page-wrapper" }}
|
||||||
<div class="container">
|
<div class="container" id="page-content">
|
||||||
|
|
||||||
<!-- Outer Row -->
|
<!-- Outer Row -->
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
@ -42,7 +42,7 @@
|
|||||||
</form>
|
</form>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a class="small" href="/user/forgot-password">Forgot Password?</a>
|
<a class="small" href="/user/reset-password">Forgot Password?</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<a class="small" href="/signup">Create an Account!</a>
|
<a class="small" href="/signup">Create an Account!</a>
|
||||||
|
69
cmd/web-app/templates/content/user-reset-confirm.gohtml
Normal file
69
cmd/web-app/templates/content/user-reset-confirm.gohtml
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{{define "title"}}Reset Password{{end}}
|
||||||
|
{{define "style"}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
{{ define "partials/page-wrapper" }}
|
||||||
|
<div class="container" id="page-content">
|
||||||
|
|
||||||
|
<!-- Outer Row -->
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
|
||||||
|
<div class="col-xl-10 col-lg-12 col-md-9">
|
||||||
|
|
||||||
|
<div class="card o-hidden border-0 shadow-lg my-5">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<!-- Nested Row within Card Body -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="h4 text-gray-900 mb-2">Reset Your Password</h1>
|
||||||
|
<p class="mb-4">.....</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ template "validation-error" . }}
|
||||||
|
|
||||||
|
<form class="user" method="post" novalidate>
|
||||||
|
<input type="hidden" name="ResetHash" value="{{ $.form.ResetHash }}" />
|
||||||
|
<div class="form-group row">
|
||||||
|
<div class="col-sm-6 mb-3 mb-sm-0">
|
||||||
|
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Password" }}" name="Password" value="{{ $.form.Password }}" placeholder="Password" required>
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Password" }}
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-6">
|
||||||
|
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "PasswordConfirm" }}" name="PasswordConfirm" value="{{ $.form.PasswordConfirm }}" placeholder="Repeat Password" required>
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "PasswordConfirm" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-user btn-block">
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
<hr>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div class="text-center">
|
||||||
|
<a class="small" href="/user/login">Already have an account? Login!</a>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<a class="small" href="/signup">Create an Account!</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{define "js"}}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$(document).find('body').addClass('bg-gradient-primary');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
59
cmd/web-app/templates/content/user-reset-password.gohtml
Normal file
59
cmd/web-app/templates/content/user-reset-password.gohtml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
{{define "title"}}User Forgot Password{{end}}
|
||||||
|
{{define "style"}}
|
||||||
|
|
||||||
|
{{end}}
|
||||||
|
{{ define "partials/page-wrapper" }}
|
||||||
|
<div class="container" id="page-content">
|
||||||
|
|
||||||
|
<!-- Outer Row -->
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
|
||||||
|
<div class="col-xl-10 col-lg-12 col-md-9">
|
||||||
|
|
||||||
|
<div class="card o-hidden border-0 shadow-lg my-5">
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<!-- Nested Row within Card Body -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6 d-none d-lg-block bg-login-image"></div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="h4 text-gray-900 mb-2">Forgot Your Password?</h1>
|
||||||
|
<p class="mb-4">We get it, stuff happens. Just enter your email address below and we'll send you a link to reset your password!</p>
|
||||||
|
</div>
|
||||||
|
<form class="user" method="post" novalidate>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="email" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "Email" }}" name="Email" value="{{ $.form.Email }}" placeholder="Enter Email Address...">
|
||||||
|
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "Email" }}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-user btn-block">
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
<hr>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<div class="text-center">
|
||||||
|
<a class="small" href="/user/login">Already have an account? Login!</a>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<a class="small" href="/signup">Create an Account!</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{define "js"}}
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$(document).find('body').addClass('bg-gradient-primary');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
@ -99,7 +99,7 @@
|
|||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{ define "invalid-feedback" }}
|
{{ define "invalid-feedback" }}
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback" data-field="{{ .fieldName }}">
|
||||||
{{ if ValidationErrorHasField .validationErrors .fieldName }}
|
{{ if ValidationErrorHasField .validationErrors .fieldName }}
|
||||||
{{ range $verr := (ValidationFieldErrors .validationErrors .fieldName) }}{{ $verr.Display }}<br/>{{ end }}
|
{{ range $verr := (ValidationFieldErrors .validationErrors .fieldName) }}{{ $verr.Display }}<br/>{{ end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@ -128,3 +128,20 @@
|
|||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ define "validation-error" }}
|
||||||
|
{{ if .validationErrors }}
|
||||||
|
{{ $errMsg := (ErrorMessage $._Ctx .validationErrors) }}
|
||||||
|
{{ if $errMsg }}
|
||||||
|
<div class="alert alert-danger validation-error" role="alert">
|
||||||
|
<button type="button" class="close" data-dismiss="alert" aria-label="Close"> <span aria-hidden="true">×</span> </button> {{ if $errMsg }}<h3>{{ $errMsg }}</h3> {{end}}
|
||||||
|
{{ if .validationErrors.Fields }}
|
||||||
|
<ul>
|
||||||
|
{{ range $i := .validationErrors.Fields }}
|
||||||
|
<li data-form-field="{{ $i.FormField }}">{{ if $i.Display }}{{ $i.Display }}{{ else }}{{ $i.Error }}{{ end }}</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
@ -23,13 +23,13 @@
|
|||||||
|
|
||||||
{{ template "top-error" . }}
|
{{ template "top-error" . }}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- ============================================================== -->
|
<!-- ============================================================== -->
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<!-- ============================================================== -->
|
<!-- ============================================================== -->
|
||||||
<div class="container-fluid">
|
<div class="container-fluid" id="page-content">
|
||||||
|
|
||||||
|
{{ template "validation-error" . }}
|
||||||
|
|
||||||
{{ template "content" . }}
|
{{ template "content" . }}
|
||||||
</div>
|
</div>
|
||||||
<!-- End Page Content -->
|
<!-- End Page Content -->
|
||||||
|
2
go.mod
2
go.mod
@ -41,6 +41,7 @@ require (
|
|||||||
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0
|
github.com/sethgrid/pester v0.0.0-20190127155807-68a33a018ad0
|
||||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24
|
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24
|
||||||
github.com/stretchr/testify v1.3.0
|
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/files v0.0.0-20190704085106-630677cd5c14
|
||||||
github.com/swaggo/swag v1.6.2
|
github.com/swaggo/swag v1.6.2
|
||||||
github.com/tinylib/msgp v1.1.0 // indirect
|
github.com/tinylib/msgp v1.1.0 // indirect
|
||||||
@ -55,6 +56,7 @@ require (
|
|||||||
google.golang.org/appengine v1.6.1 // indirect
|
google.golang.org/appengine v1.6.1 // indirect
|
||||||
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1
|
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1
|
||||||
gopkg.in/go-playground/validator.v9 v9.29.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
|
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce
|
||||||
gotest.tools v2.2.0+incompatible // indirect
|
gotest.tools v2.2.0+incompatible // indirect
|
||||||
)
|
)
|
||||||
|
4
go.sum
4
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
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 h1:PyYN9JH5jY9j6av01SpfRMb+1DWg/i3MbGOKPxJ2wjM=
|
||||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
|
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=
|
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/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 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
|
||||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
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 h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
|
||||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
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=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
60
internal/platform/notify/email.go
Normal file
60
internal/platform/notify/email.go
Normal file
@ -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
|
||||||
|
}
|
126
internal/platform/notify/email_aws.go
Normal file
126
internal/platform/notify/email_aws.go
Normal file
@ -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
|
||||||
|
}
|
66
internal/platform/notify/email_smtp.go
Normal file
66
internal/platform/notify/email_smtp.go
Normal file
@ -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
|
||||||
|
}
|
@ -129,6 +129,7 @@ func Context() context.Context {
|
|||||||
values := webcontext.Values{
|
values := webcontext.Values{
|
||||||
TraceID: uint64(time.Now().UnixNano()),
|
TraceID: uint64(time.Now().UnixNano()),
|
||||||
Now: time.Now(),
|
Now: time.Now(),
|
||||||
|
RequestIP: "68.69.35.104",
|
||||||
}
|
}
|
||||||
|
|
||||||
return context.WithValue(context.Background(), webcontext.KeyValues, &values)
|
return context.WithValue(context.Background(), webcontext.KeyValues, &values)
|
||||||
|
@ -69,6 +69,7 @@ func (a *App) Handle(verb, path string, handler Handler, mw ...Middleware) {
|
|||||||
v := webcontext.Values{
|
v := webcontext.Values{
|
||||||
Now: time.Now(),
|
Now: time.Now(),
|
||||||
Env: a.env,
|
Env: a.env,
|
||||||
|
RequestIP: RequestRealIP(r),
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), webcontext.KeyValues, &v)
|
ctx := context.WithValue(r.Context(), webcontext.KeyValues, &v)
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ type Values struct {
|
|||||||
SpanID uint64
|
SpanID uint64
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Env Env
|
Env Env
|
||||||
|
RequestIP string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ContextValues(ctx context.Context) (*Values, error) {
|
func ContextValues(ctx context.Context) (*Values, error) {
|
||||||
|
@ -39,3 +39,9 @@ func (r ProjectRoutes) WebApiUrl(urlPath string) string {
|
|||||||
u.Path = urlPath
|
u.Path = urlPath
|
||||||
return u.String()
|
return u.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r ProjectRoutes) UserResetPassword(resetId string) string {
|
||||||
|
u := r.webAppUrl
|
||||||
|
u.Path = "/user/reset-password/" + resetId
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
@ -72,6 +72,11 @@ type UserCreateRequest struct {
|
|||||||
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
|
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
|
// 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
|
// 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
|
// 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"`
|
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
|
// UserFindRequest defines the possible options to search for users. By default
|
||||||
// archived users will be excluded from response.
|
// archived users will be excluded from response.
|
||||||
type UserFindRequest struct {
|
type UserFindRequest struct {
|
||||||
@ -110,6 +120,27 @@ type UserFindRequest struct {
|
|||||||
IncludedArchived bool `json:"included-archived" example:"false"`
|
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.
|
// AuthenticateRequest defines what information is required to authenticate a user.
|
||||||
type AuthenticateRequest struct {
|
type AuthenticateRequest struct {
|
||||||
Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"`
|
Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"`
|
||||||
|
@ -3,9 +3,13 @@ package user
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"github.com/sudo-suhas/symcrypto"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
|
"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/platform/web/webcontext"
|
||||||
"github.com/huandu/go-sqlbuilder"
|
"github.com/huandu/go-sqlbuilder"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
@ -34,6 +38,9 @@ var (
|
|||||||
// ErrAuthenticationFailure occurs when a user attempts to authenticate but
|
// ErrAuthenticationFailure occurs when a user attempts to authenticate but
|
||||||
// anything goes wrong.
|
// anything goes wrong.
|
||||||
ErrAuthenticationFailure = errors.New("Authentication failed")
|
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
|
// 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
|
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.
|
// 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) {
|
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")
|
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))
|
query.Where(query.Equal("id", id))
|
||||||
|
|
||||||
res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived)
|
res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived)
|
||||||
if res == nil || len(res) == 0 {
|
if err != nil {
|
||||||
err = errors.WithMessagef(ErrNotFound, "user %s not found", id)
|
|
||||||
return nil, err
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
u := res[0]
|
u := res[0]
|
||||||
@ -604,6 +679,57 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA
|
|||||||
return nil
|
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.
|
// Delete removes a user from the database.
|
||||||
func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error {
|
func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error {
|
||||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Delete")
|
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
|
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
|
||||||
|
}
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
|
"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/platform/tests"
|
||||||
"github.com/dgrijalva/jwt-go"
|
"github.com/dgrijalva/jwt-go"
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
@ -583,7 +584,7 @@ func TestUpdatePassword(t *testing.T) {
|
|||||||
}, now)
|
}, now)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Log("\t\tGot :", err)
|
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)
|
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)
|
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.
|
// Delete (hard-delete) the user.
|
||||||
err = Delete(ctx, tt.claims(user, accountId), test.MasterDB, user.ID)
|
err = Delete(ctx, tt.claims(user, accountId), test.MasterDB, user.ID)
|
||||||
if err != nil && errors.Cause(err) != tt.updateErr {
|
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 {
|
func mockUserAccount(userId, accountId string, now time.Time, roles ...string) error {
|
||||||
var roleArr pq.StringArray
|
var roleArr pq.StringArray
|
||||||
for _, r := range roles {
|
for _, r := range roles {
|
||||||
|
271
internal/user_account/invite/invite.go
Normal file
271
internal/user_account/invite/invite.go
Normal file
@ -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
|
||||||
|
}
|
266
internal/user_account/invite/invite_test.go
Normal file
266
internal/user_account/invite/invite_test.go
Normal file
@ -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
|
||||||
|
}
|
34
internal/user_account/invite/models.go
Normal file
34
internal/user_account/invite/models.go
Normal file
@ -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"`
|
||||||
|
}
|
@ -3,11 +3,11 @@ package user_account
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/account"
|
|
||||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
|
||||||
"time"
|
"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/auth"
|
||||||
|
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
||||||
"github.com/huandu/go-sqlbuilder"
|
"github.com/huandu/go-sqlbuilder"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/pborman/uuid"
|
"github.com/pborman/uuid"
|
||||||
@ -26,6 +26,9 @@ var (
|
|||||||
// The database table for UserAccount
|
// The database table for UserAccount
|
||||||
const userAccountTableName = "users_accounts"
|
const userAccountTableName = "users_accounts"
|
||||||
|
|
||||||
|
// The database table for User
|
||||||
|
const userTableName = "users"
|
||||||
|
|
||||||
// The list of columns needed for mapRowsToUserAccount
|
// The list of columns needed for mapRowsToUserAccount
|
||||||
var userAccountMapColumns = "id,user_id,account_id,roles,status,created_at,updated_at,archived_at"
|
var userAccountMapColumns = "id,user_id,account_id,roles,status,created_at,updated_at,archived_at"
|
||||||
|
|
||||||
|
19
resources/templates/shared/emails/user_invite.html
Normal file
19
resources/templates/shared/emails/user_invite.html
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<link href="https://fonts.googleapis.com/css?family=Poppins|Roboto" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ccc;
|
||||||
|
color: #333;
|
||||||
|
padding: 0 0 0 0;
|
||||||
|
margin: 0 0 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="padding: 0% 10% 10% 10%">
|
||||||
|
<div style="padding: 10% 10% 10% 10%; background: white; word-wrap: break-word; border-radius: 10px 10px 10px 10px; ">
|
||||||
|
<p>{{ .FromUser.FirstName }} has invited you to join {{ .Account.Name }}.</p>
|
||||||
|
<p>To accept the invite, follow this link (or paste into your browser) within the next {{ .Minutes }} minutes.</p>
|
||||||
|
<p><a href="{{ .Url }}" target="_blank">{{ .Url }}</a></p>
|
||||||
|
<p> <br/>- Support </p>
|
||||||
|
</div>
|
||||||
|
</div>
|
4
resources/templates/shared/emails/user_invite.txt
Normal file
4
resources/templates/shared/emails/user_invite.txt
Normal file
@ -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 }}
|
20
resources/templates/shared/emails/user_reset_password.html
Normal file
20
resources/templates/shared/emails/user_reset_password.html
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<link href="https://fonts.googleapis.com/css?family=Poppins|Roboto" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #ccc;
|
||||||
|
color: #333;
|
||||||
|
padding: 0 0 0 0;
|
||||||
|
margin: 0 0 0 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div style="padding: 0% 10% 10% 10%">
|
||||||
|
<div style="padding: 10% 10% 10% 10%; background: white; word-wrap: break-word; border-radius: 10px 10px 10px 10px; ">
|
||||||
|
<p>{{ .Name }},</p>
|
||||||
|
<p>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.</p>
|
||||||
|
<p>To reset your password, follow this link (or paste into your browser) within the next {{ .Minutes }} minutes.</p>
|
||||||
|
<p><a href="{{ .Url }}" target="_blank">{{ .Url }}</a></p>
|
||||||
|
<p> <br/>- Support</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -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 }}
|
@ -514,6 +514,7 @@ func NewServiceDeployRequest(log *log.Logger, flags ServiceDeployFlags) (*servic
|
|||||||
"route53:ChangeResourceRecordSets",
|
"route53:ChangeResourceRecordSets",
|
||||||
"ecs:UpdateService",
|
"ecs:UpdateService",
|
||||||
"ses:SendEmail",
|
"ses:SendEmail",
|
||||||
|
"ses:ListIdentities",
|
||||||
"secretsmanager:ListSecretVersionIds",
|
"secretsmanager:ListSecretVersionIds",
|
||||||
"secretsmanager:GetSecretValue",
|
"secretsmanager:GetSecretValue",
|
||||||
"secretsmanager:CreateSecret",
|
"secretsmanager:CreateSecret",
|
||||||
|
Reference in New Issue
Block a user