1
0
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:
Lee Brown
2019-08-02 15:03:32 -08:00
parent 1d69ea88a3
commit c625ace88d
40 changed files with 1861 additions and 37 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
aws.lee aws.lee
aws.* aws.*
.env_docker_compose .env_docker_compose
local.env

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.');
}
}
});
});
});

View File

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

View File

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

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

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

View File

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

View File

@ -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
View File

@ -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
View File

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

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

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

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

View File

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

View File

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

View File

@ -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) {

View File

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

View File

@ -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"`

View File

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

View File

@ -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 := &notify.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 {

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

View 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 := &notify.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
}

View 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"`
}

View File

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

View 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>&nbsp;<br/>- Support </p>
</div>
</div>

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

View 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>&nbsp;<br/>- Support</p>
</div>
</div>

View File

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

View File

@ -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",