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.*
|
||||
.env_docker_compose
|
||||
local.env
|
||||
|
@ -92,6 +92,7 @@ webapp:deploy:dev:
|
||||
STATIC_FILES_S3: 'true'
|
||||
STATIC_FILES_IMG_RESIZE: 'true'
|
||||
AWS_USE_ROLE: 'true'
|
||||
EMAIL_SENDER: 'lee@geeksinthewoods.com'
|
||||
|
||||
webapi:build:dev:
|
||||
<<: *build_tmpl
|
||||
@ -131,6 +132,7 @@ webapi:deploy:dev:
|
||||
STATIC_FILES_S3: 'false'
|
||||
STATIC_FILES_IMG_RESIZE: 'false'
|
||||
AWS_USE_ROLE: 'true'
|
||||
EMAIL_SENDER: 'lee@geeksinthewoods.com'
|
||||
|
||||
#ddlogscollector:deploy:stage:
|
||||
# <<: *deploy_stage_tmpl
|
||||
|
@ -381,7 +381,7 @@ schema migrations before running any unit tests.
|
||||
To login to the local Postgres container, use the following command:
|
||||
```bash
|
||||
docker exec -it saas-starter-kit_postgres_1 /bin/bash
|
||||
bash-4.4# psql -U postgres shared
|
||||
bash-5.0# psql -U postgres shared
|
||||
```
|
||||
|
||||
The example project currently only includes a few tables. As more functionality is built into both the web-app and
|
||||
|
@ -37,6 +37,9 @@ COPY cmd/web-api ./cmd/web-api
|
||||
COPY cmd/web-api/templates /templates
|
||||
#COPY cmd/web-api/static /static
|
||||
|
||||
# Copy the global templates.
|
||||
ADD resources/templates/shared /templates/shared
|
||||
|
||||
WORKDIR ./cmd/web-api
|
||||
|
||||
# Update the API documentation.
|
||||
@ -54,6 +57,8 @@ COPY --from=builder /gosrv /
|
||||
COPY --from=builder /templates /templates
|
||||
|
||||
ENV TEMPLATE_DIR=/templates
|
||||
ENV SHARED_TEMPLATE_DIR=/templates/shared
|
||||
#ENV STATIC_DIR=/static
|
||||
|
||||
ARG service
|
||||
ENV SERVICE_NAME $service
|
||||
|
@ -38,6 +38,7 @@
|
||||
{"name": "WEB_API_SERVICE_BASE_URL", "value": "{APP_BASE_URL}"},
|
||||
{"name": "WEB_API_SERVICE_HOST_NAMES", "value": "{HOST_NAMES}"},
|
||||
{"name": "WEB_API_SERVICE_ENABLE_HTTPS", "value": "{HTTPS_ENABLED}"},
|
||||
{"name": "WEB_API_EMAIL_SENDER", "value": "{EMAIL_SENDER}"},
|
||||
{"name": "WEB_API_REDIS_HOST", "value": "{CACHE_HOST}"},
|
||||
{"name": "WEB_API_DB_HOST", "value": "{DB_HOST}"},
|
||||
{"name": "WEB_API_DB_USER", "value": "{DB_USER}"},
|
||||
|
@ -2,3 +2,4 @@ export WEB_API_DB_HOST=127.0.0.1:5433
|
||||
export WEB_API_DB_USER=postgres
|
||||
export WEB_API_DB_PASS=postgres
|
||||
export WEB_API_DB_DISABLE_TLS=true
|
||||
export WEB_API_SERVICE_EMAIL_SENDER=valdez@example.com
|
||||
|
@ -24,6 +24,9 @@ COPY cmd/web-app ./cmd/web-app
|
||||
COPY cmd/web-app/templates /templates
|
||||
COPY cmd/web-app/static /static
|
||||
|
||||
# Copy the global templates.
|
||||
ADD resources/templates/shared /templates/shared
|
||||
|
||||
WORKDIR ./cmd/web-app
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix nocgo -o /gosrv .
|
||||
@ -38,6 +41,7 @@ COPY --from=builder /static /static
|
||||
COPY --from=builder /templates /templates
|
||||
|
||||
ENV TEMPLATE_DIR=/templates
|
||||
ENV SHARED_TEMPLATE_DIR=/templates/shared
|
||||
ENV STATIC_DIR=/static
|
||||
|
||||
ARG service
|
||||
|
@ -42,6 +42,7 @@
|
||||
{"name": "WEB_APP_SERVICE_STATICFILES_S3_PREFIX", "value": "{STATIC_FILES_S3_PREFIX}"},
|
||||
{"name": "WEB_APP_SERVICE_STATICFILES_CLOUDFRONT_ENABLED", "value": "{STATIC_FILES_CLOUDFRONT_ENABLED}"},
|
||||
{"name": "WEB_APP_SERVICE_STATICFILES_IMG_RESIZE_ENABLED", "value": "{STATIC_FILES_IMG_RESIZE_ENABLED}"},
|
||||
{"name": "WEB_APP_EMAIL_SENDER", "value": "{EMAIL_SENDER}"},
|
||||
{"name": "WEB_APP_REDIS_HOST", "value": "{CACHE_HOST}"},
|
||||
{"name": "WEB_APP_DB_HOST", "value": "{DB_HOST}"},
|
||||
{"name": "WEB_APP_DB_USER", "value": "{DB_USER}"},
|
||||
|
@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@ -23,7 +24,7 @@ const (
|
||||
)
|
||||
|
||||
// API returns a handler for a set of routes.
|
||||
func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, projectRoutes project_routes.ProjectRoutes, renderer web.Renderer, globalMids ...web.Middleware) http.Handler {
|
||||
func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir, templateDir string, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, projectRoutes project_routes.ProjectRoutes, secretKey string, notifyEmail notify.Email, renderer web.Renderer, globalMids ...web.Middleware) http.Handler {
|
||||
|
||||
// Define base middlewares applied to all requests.
|
||||
middlewares := []web.Middleware{
|
||||
@ -50,13 +51,18 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir
|
||||
MasterDB: masterDB,
|
||||
Renderer: renderer,
|
||||
Authenticator: authenticator,
|
||||
ProjectRoutes: projectRoutes,
|
||||
NotifyEmail: notifyEmail,
|
||||
SecretKey: secretKey,
|
||||
}
|
||||
// This route is not authenticated
|
||||
app.Handle("POST", "/user/login", u.Login)
|
||||
app.Handle("GET", "/user/login", u.Login)
|
||||
app.Handle("GET", "/user/logout", u.Logout)
|
||||
app.Handle("POST", "/user/forgot-password", u.ForgotPassword)
|
||||
app.Handle("GET", "/user/forgot-password", u.ForgotPassword)
|
||||
app.Handle("POST", "/user/reset-password/:hash", u.ResetConfirm)
|
||||
app.Handle("GET", "/user/reset-password/:hash", u.ResetConfirm)
|
||||
app.Handle("POST", "/user/reset-password", u.ResetPassword)
|
||||
app.Handle("GET", "/user/reset-password", u.ResetPassword)
|
||||
|
||||
// Register user management and authentication endpoints.
|
||||
s := Signup{
|
||||
|
@ -2,6 +2,8 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||
project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -23,6 +25,9 @@ type User struct {
|
||||
MasterDB *sqlx.DB
|
||||
Renderer web.Renderer
|
||||
Authenticator *auth.Authenticator
|
||||
ProjectRoutes project_routes.ProjectRoutes
|
||||
NotifyEmail notify.Email
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
type UserLoginRequest struct {
|
||||
@ -156,7 +161,156 @@ func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
// List returns all the existing users in the system.
|
||||
func (h *User) ForgotPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
|
||||
func (h *User) ResetPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
|
||||
|
||||
return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-forgot-password.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil)
|
||||
ctxValues, err := webcontext.ContextValues(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//
|
||||
req := new(user.UserResetPasswordRequest)
|
||||
data := make(map[string]interface{})
|
||||
f := func() error {
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decoder := schema.NewDecoder()
|
||||
if err := decoder.Decode(req, r.PostForm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := webcontext.Validator().Struct(req); err != nil {
|
||||
if ne, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = ne.(*weberror.Error)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, err = user.ResetPassword(ctx, h.MasterDB, h.ProjectRoutes.UserResetPassword, h.NotifyEmail, *req, h.SecretKey, ctxValues.Now)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
default:
|
||||
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = verr.(*weberror.Error)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Display a flash message!!!
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := f(); err != nil {
|
||||
return web.RenderError(ctx, w, r, err, h.Renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
|
||||
}
|
||||
|
||||
data["form"] = req
|
||||
|
||||
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(user.UserResetPasswordRequest{})); ok {
|
||||
data["validationDefaults"] = verr.(*weberror.Error)
|
||||
}
|
||||
|
||||
return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-reset-password.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// List returns all the existing users in the system.
|
||||
func (h *User) ResetConfirm(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error {
|
||||
|
||||
ctxValues, err := webcontext.ContextValues(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//
|
||||
req := new(user.UserResetConfirmRequest)
|
||||
data := make(map[string]interface{})
|
||||
f := func() error {
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
decoder := schema.NewDecoder()
|
||||
if err := decoder.Decode(req, r.PostForm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := webcontext.Validator().Struct(req); err != nil {
|
||||
if ne, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = ne.(*weberror.Error)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
u, err := user.ResetConfirm(ctx, h.MasterDB, *req, h.SecretKey, ctxValues.Now)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
default:
|
||||
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = verr.(*weberror.Error)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated the user. Probably should use the default session TTL from UserLogin.
|
||||
token, err := user.Authenticate(ctx, h.MasterDB, h.Authenticator, u.Email, req.Password, time.Hour, ctxValues.Now)
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
case account.ErrForbidden:
|
||||
return web.RespondError(ctx, w, weberror.NewError(ctx, err, http.StatusForbidden))
|
||||
default:
|
||||
if verr, ok := weberror.NewValidationError(ctx, err); ok {
|
||||
data["validationErrors"] = verr.(*weberror.Error)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the token to the users session.
|
||||
err = handleSessionToken(ctx, w, r, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Redirect the user to the dashboard.
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
} else {
|
||||
req.ResetHash = params["hash"]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := f(); err != nil {
|
||||
return web.RenderError(ctx, w, r, err, h.Renderer, tmplLayoutBase, tmplContentErrorGeneric, web.MIMETextHTMLCharsetUTF8)
|
||||
}
|
||||
|
||||
data["form"] = req
|
||||
|
||||
if verr, ok := weberror.NewValidationError(ctx, webcontext.Validator().Struct(user.UserResetConfirmRequest{})); ok {
|
||||
data["validationDefaults"] = verr.(*weberror.Error)
|
||||
}
|
||||
|
||||
return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-reset-confirm.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data)
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"expvar"
|
||||
"fmt"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||
"gopkg.in/gomail.v2"
|
||||
"html/template"
|
||||
"log"
|
||||
"net"
|
||||
@ -81,13 +83,14 @@ func main() {
|
||||
DisableHTTP2 bool `default:"false" envconfig:"DISABLE_HTTP2"`
|
||||
}
|
||||
Service struct {
|
||||
Name string `default:"web-app" envconfig:"NAME"`
|
||||
Project string `default:"" envconfig:"PROJECT"`
|
||||
BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://eproc.tech"`
|
||||
HostNames []string `envconfig:"HOST_NAMES" example:"www.eproc.tech"`
|
||||
EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"`
|
||||
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
|
||||
StaticFiles struct {
|
||||
Name string `default:"web-app" envconfig:"NAME"`
|
||||
Project string `default:"" envconfig:"PROJECT"`
|
||||
BaseUrl string `default:"" envconfig:"BASE_URL" example:"http://eproc.tech"`
|
||||
HostNames []string `envconfig:"HOST_NAMES" example:"www.eproc.tech"`
|
||||
EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"`
|
||||
TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"`
|
||||
SharedTemplateDir string `default:"../../resources/templates/shared" envconfig:"SHARED_TEMPLATE_DIR"`
|
||||
StaticFiles struct {
|
||||
Dir string `default:"./static" envconfig:"STATIC_DIR"`
|
||||
S3Enabled bool `envconfig:"S3_ENABLED"`
|
||||
S3Prefix string `default:"public/web_app/static" envconfig:"S3_PREFIX"`
|
||||
@ -97,6 +100,7 @@ func main() {
|
||||
WebApiBaseUrl string `default:"http://127.0.0.1:3001" envconfig:"WEB_API_BASE_URL" example:"http://api.eproc.tech"`
|
||||
SessionKey string `default:"" envconfig:"SESSION_KEY"`
|
||||
SessionName string `default:"" envconfig:"SESSION_NAME"`
|
||||
EmailSender string `default:"" envconfig:"EMAIL_SENDER"`
|
||||
DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"`
|
||||
ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"`
|
||||
}
|
||||
@ -137,6 +141,12 @@ func main() {
|
||||
UseAwsSecretManager bool `default:"false" envconfig:"USE_AWS_SECRET_MANAGER"`
|
||||
KeyExpiration time.Duration `default:"3600s" envconfig:"KEY_EXPIRATION"`
|
||||
}
|
||||
STMP struct {
|
||||
Host string `default:"localhost" envconfig:"HOST"`
|
||||
Port int `default:"25" envconfig:"PORT"`
|
||||
User string `default:"" envconfig:"USER"`
|
||||
Pass string `default:"" envconfig:"PASS" json:"-"` // don't print
|
||||
}
|
||||
BuildInfo struct {
|
||||
CiCommitRefName string `envconfig:"CI_COMMIT_REF_NAME"`
|
||||
CiCommitRefSlug string `envconfig:"CI_COMMIT_REF_SLUG"`
|
||||
@ -351,6 +361,38 @@ func main() {
|
||||
}
|
||||
defer masterDb.Close()
|
||||
|
||||
// =========================================================================
|
||||
// Notify Email
|
||||
var notifyEmail notify.Email
|
||||
if awsSession != nil {
|
||||
notifyEmail, err = notify.NewEmailAws(awsSession, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender)
|
||||
if err != nil {
|
||||
log.Fatalf("main : Notify Email : %+v", err)
|
||||
}
|
||||
|
||||
err = notifyEmail.Verify()
|
||||
if err != nil {
|
||||
switch errors.Cause(err) {
|
||||
case notify.ErrAwsSesIdentityNotVerified:
|
||||
log.Printf("main : Notify Email : %s\n", err)
|
||||
case notify.ErrAwsSesSendingDisabled:
|
||||
log.Printf("main : Notify Email : %s\n", err)
|
||||
default:
|
||||
log.Fatalf("main : Notify Email Verify : %+v", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
d := gomail.Dialer{
|
||||
Host: cfg.STMP.Host,
|
||||
Port: cfg.STMP.Port,
|
||||
Username: cfg.STMP.User,
|
||||
Password: cfg.STMP.Pass}
|
||||
notifyEmail, err = notify.NewEmailSmtp(d, cfg.Service.SharedTemplateDir, cfg.Service.EmailSender)
|
||||
if err != nil {
|
||||
log.Fatalf("main : Notify Email : %+v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Init new Authenticator
|
||||
var authenticator *auth.Authenticator
|
||||
@ -776,7 +818,7 @@ func main() {
|
||||
if cfg.HTTP.Host != "" {
|
||||
api := http.Server{
|
||||
Addr: cfg.HTTP.Host,
|
||||
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, renderer, serviceMiddlewares...),
|
||||
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...),
|
||||
ReadTimeout: cfg.HTTP.ReadTimeout,
|
||||
WriteTimeout: cfg.HTTP.WriteTimeout,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
@ -793,7 +835,7 @@ func main() {
|
||||
if cfg.HTTPS.Host != "" {
|
||||
api := http.Server{
|
||||
Addr: cfg.HTTPS.Host,
|
||||
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, renderer, serviceMiddlewares...),
|
||||
Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, cfg.Service.SessionKey, notifyEmail, renderer, serviceMiddlewares...),
|
||||
ReadTimeout: cfg.HTTPS.ReadTimeout,
|
||||
WriteTimeout: cfg.HTTPS.WriteTimeout,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
|
@ -2,3 +2,4 @@ export WEB_APP_DB_HOST=127.0.0.1:5433
|
||||
export WEB_APP_DB_USER=postgres
|
||||
export WEB_APP_DB_PASS=postgres
|
||||
export WEB_APP_DB_DISABLE_TLS=true
|
||||
export WEB_APP_SERVICE_EMAIL_SENDER=valdez@example.com
|
@ -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}}
|
||||
{{ 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-body p-0">
|
||||
@ -102,7 +102,7 @@
|
||||
</form>
|
||||
<hr>
|
||||
<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 class="text-center">
|
||||
<a class="small" href="/user/login">Already have an account? Login!</a>
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
{{end}}
|
||||
{{ define "partials/page-wrapper" }}
|
||||
<div class="container">
|
||||
<div class="container" id="page-content">
|
||||
|
||||
<!-- Outer Row -->
|
||||
<div class="row justify-content-center">
|
||||
@ -42,7 +42,7 @@
|
||||
</form>
|
||||
<hr>
|
||||
<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 class="text-center">
|
||||
<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>
|
||||
{{end}}
|
||||
{{ define "invalid-feedback" }}
|
||||
<div class="invalid-feedback">
|
||||
<div class="invalid-feedback" data-field="{{ .fieldName }}">
|
||||
{{ if ValidationErrorHasField .validationErrors .fieldName }}
|
||||
{{ range $verr := (ValidationFieldErrors .validationErrors .fieldName) }}{{ $verr.Display }}<br/>{{ end }}
|
||||
{{ else }}
|
||||
@ -128,3 +128,20 @@
|
||||
{{ 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" . }}
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- ============================================================== -->
|
||||
<!-- Page Content -->
|
||||
<!-- ============================================================== -->
|
||||
<div class="container-fluid">
|
||||
<div class="container-fluid" id="page-content">
|
||||
|
||||
{{ template "validation-error" . }}
|
||||
|
||||
{{ template "content" . }}
|
||||
</div>
|
||||
<!-- 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/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24
|
||||
github.com/stretchr/testify v1.3.0
|
||||
github.com/sudo-suhas/symcrypto v1.0.0
|
||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14
|
||||
github.com/swaggo/swag v1.6.2
|
||||
github.com/tinylib/msgp v1.1.0 // indirect
|
||||
@ -55,6 +56,7 @@ require (
|
||||
google.golang.org/appengine v1.6.1 // indirect
|
||||
gopkg.in/DataDog/dd-trace-go.v1 v1.16.1
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce
|
||||
gotest.tools v2.2.0+incompatible // indirect
|
||||
)
|
||||
|
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.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/sudo-suhas/symcrypto v1.0.0 h1:VG6FdACf5XeXFQUzeA++aB6snNThz0OFlmUHiCddi2s=
|
||||
github.com/sudo-suhas/symcrypto v1.0.0/go.mod h1:g/faGDjhlF/DXdqp3+SQ0LmhPcv4iYaIRjcm/Q60+68=
|
||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 h1:PyYN9JH5jY9j6av01SpfRMb+1DWg/i3MbGOKPxJ2wjM=
|
||||
github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14/go.mod h1:gxQT6pBGRuIGunNf/+tSOB5OHvguWi8Tbt82WOkf35E=
|
||||
github.com/swaggo/swag v1.6.2 h1:WQMAtT/FmMBb7g0rAuHDhG3vvdtHKJ3WZ+Ssb0p4Y6E=
|
||||
@ -199,6 +201,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXa
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1 h1:SvGtYmN60a5CVKTOzMSyfzWDeZRxRuGvRQyEAKbw1xc=
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
|
||||
gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce h1:xcEWjVhvbDy+nHP67nPDDpbYrY+ILlfndk4bRioVHaU=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
|
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
|
||||
}
|
@ -127,8 +127,9 @@ func Recover(t *testing.T) {
|
||||
// Context returns an app level context for testing.
|
||||
func Context() context.Context {
|
||||
values := webcontext.Values{
|
||||
TraceID: uint64(time.Now().UnixNano()),
|
||||
Now: time.Now(),
|
||||
TraceID: uint64(time.Now().UnixNano()),
|
||||
Now: time.Now(),
|
||||
RequestIP: "68.69.35.104",
|
||||
}
|
||||
|
||||
return context.WithValue(context.Background(), webcontext.KeyValues, &values)
|
||||
|
@ -67,8 +67,9 @@ func (a *App) Handle(verb, path string, handler Handler, mw ...Middleware) {
|
||||
// Set the context with the required values to
|
||||
// process the request.
|
||||
v := webcontext.Values{
|
||||
Now: time.Now(),
|
||||
Env: a.env,
|
||||
Now: time.Now(),
|
||||
Env: a.env,
|
||||
RequestIP: RequestRealIP(r),
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), webcontext.KeyValues, &v)
|
||||
|
||||
|
@ -22,6 +22,7 @@ type Values struct {
|
||||
SpanID uint64
|
||||
StatusCode int
|
||||
Env Env
|
||||
RequestIP string
|
||||
}
|
||||
|
||||
func ContextValues(ctx context.Context) (*Values, error) {
|
||||
|
@ -39,3 +39,9 @@ func (r ProjectRoutes) WebApiUrl(urlPath string) string {
|
||||
u.Path = urlPath
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (r ProjectRoutes) UserResetPassword(resetId string) string {
|
||||
u := r.webAppUrl
|
||||
u.Path = "/user/reset-password/" + resetId
|
||||
return u.String()
|
||||
}
|
||||
|
@ -72,6 +72,11 @@ type UserCreateRequest struct {
|
||||
Timezone *string `json:"timezone,omitempty" validate:"omitempty" example:"America/Anchorage"`
|
||||
}
|
||||
|
||||
// UserCreateInviteRequest contains information needed to create a new User.
|
||||
type UserCreateInviteRequest struct {
|
||||
Email string `json:"email" validate:"required,email,unique" example:"gabi@geeksinthewoods.com"`
|
||||
}
|
||||
|
||||
// UserUpdateRequest defines what information may be provided to modify an existing
|
||||
// User. All fields are optional so clients can send just the fields they want
|
||||
// changed. It uses pointer fields so we can differentiate between a field that
|
||||
@ -99,6 +104,11 @@ type UserArchiveRequest struct {
|
||||
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
||||
}
|
||||
|
||||
// UserUnarchiveRequest defines the information needed to unarchive an user.
|
||||
type UserUnarchiveRequest struct {
|
||||
ID string `json:"id" validate:"required,uuid" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
||||
}
|
||||
|
||||
// UserFindRequest defines the possible options to search for users. By default
|
||||
// archived users will be excluded from response.
|
||||
type UserFindRequest struct {
|
||||
@ -110,6 +120,27 @@ type UserFindRequest struct {
|
||||
IncludedArchived bool `json:"included-archived" example:"false"`
|
||||
}
|
||||
|
||||
// UserResetPasswordRequest defines the fields need to reset a user password.
|
||||
type UserResetPasswordRequest struct {
|
||||
Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"`
|
||||
TTL time.Duration `json:"ttl,omitempty" `
|
||||
}
|
||||
|
||||
// ResetHash
|
||||
type ResetHash struct {
|
||||
ResetID string `json:"reset_id" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
||||
CreatedAt int `json:"created_at" validate:"required"`
|
||||
ExpiresAt int `json:"expires_at" validate:"required"`
|
||||
RequestIP string `json:"request_ip" validate:"required,ip" example:"69.56.104.36"`
|
||||
}
|
||||
|
||||
// UserResetConfirmRequest defines the fields need to reset a user password.
|
||||
type UserResetConfirmRequest struct {
|
||||
ResetHash string `json:"reset_hash" validate:"required" example:"d69bdef7-173f-4d29-b52c-3edc60baf6a2"`
|
||||
Password string `json:"password" validate:"required" example:"SecretString"`
|
||||
PasswordConfirm string `json:"password_confirm" validate:"required,eqfield=Password" example:"SecretString"`
|
||||
}
|
||||
|
||||
// AuthenticateRequest defines what information is required to authenticate a user.
|
||||
type AuthenticateRequest struct {
|
||||
Email string `json:"email" validate:"required,email" example:"gabi.may@geeksinthewoods.com"`
|
||||
|
@ -3,9 +3,13 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/sudo-suhas/symcrypto"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/jmoiron/sqlx"
|
||||
@ -34,6 +38,9 @@ var (
|
||||
// ErrAuthenticationFailure occurs when a user attempts to authenticate but
|
||||
// anything goes wrong.
|
||||
ErrAuthenticationFailure = errors.New("Authentication failed")
|
||||
|
||||
// ErrResetExpired occurs when the the reset hash exceeds the expiration.
|
||||
ErrResetExpired = errors.New("Reset expired")
|
||||
)
|
||||
|
||||
// userMapColumns is the list of columns needed for mapRowsToUser
|
||||
@ -357,6 +364,74 @@ func Create(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCr
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Create invite inserts a new user into the database.
|
||||
func CreateInvite(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserCreateInviteRequest, now time.Time) (*User, error) {
|
||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.CreateInvite")
|
||||
defer span.Finish()
|
||||
|
||||
v := webcontext.Validator()
|
||||
|
||||
// Validation email address is unique in the database.
|
||||
uniq, err := UniqueEmail(ctx, dbConn, req.Email, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx = context.WithValue(ctx, webcontext.KeyTagUnique, uniq)
|
||||
|
||||
// Validate the request.
|
||||
err = v.StructCtx(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If the request has claims from a specific user, ensure that the user
|
||||
// has the correct role for creating a new user.
|
||||
if claims.Subject != "" {
|
||||
// Users with the role of admin are ony allows to create users.
|
||||
if !claims.HasRole(auth.RoleAdmin) {
|
||||
err = errors.WithStack(ErrForbidden)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// If now empty set it to the current time.
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// Always store the time as UTC.
|
||||
now = now.UTC()
|
||||
|
||||
// Postgres truncates times to milliseconds when storing. We and do the same
|
||||
// here so the value we return is consistent with what we store.
|
||||
now = now.Truncate(time.Millisecond)
|
||||
|
||||
u := User{
|
||||
ID: uuid.NewRandom().String(),
|
||||
Email: req.Email,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Build the insert SQL statement.
|
||||
query := sqlbuilder.NewInsertBuilder()
|
||||
query.InsertInto(userTableName)
|
||||
query.Cols("id", "email", "password_hash", "password_salt", "created_at", "updated_at")
|
||||
query.Values(u.ID, u.Email, "", "", u.CreatedAt, u.UpdatedAt)
|
||||
|
||||
// Execute the query with the provided context.
|
||||
sql, args := query.Build()
|
||||
sql = dbConn.Rebind(sql)
|
||||
_, err = dbConn.ExecContext(ctx, sql, args...)
|
||||
if err != nil {
|
||||
err = errors.Wrapf(err, "query - %s", query.String())
|
||||
err = errors.WithMessage(err, "create user failed")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// Read gets the specified user from the database.
|
||||
func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, includedArchived bool) (*User, error) {
|
||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Read")
|
||||
@ -367,10 +442,10 @@ func Read(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, id string, i
|
||||
query.Where(query.Equal("id", id))
|
||||
|
||||
res, err := find(ctx, claims, dbConn, query, []interface{}{}, includedArchived)
|
||||
if res == nil || len(res) == 0 {
|
||||
err = errors.WithMessagef(ErrNotFound, "user %s not found", id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if err != nil {
|
||||
} else if res == nil || len(res) == 0 {
|
||||
err = errors.WithMessagef(ErrNotFound, "user %s not found", id)
|
||||
return nil, err
|
||||
}
|
||||
u := res[0]
|
||||
@ -604,6 +679,57 @@ func Archive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserA
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unarchive undeletes the user from the database.
|
||||
func Unarchive(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req UserUnarchiveRequest, now time.Time) error {
|
||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Unarchive")
|
||||
defer span.Finish()
|
||||
|
||||
// Validate the request.
|
||||
v := webcontext.Validator()
|
||||
err := v.Struct(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure the claims can modify the user specified in the request.
|
||||
err = CanModifyUser(ctx, claims, dbConn, req.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If now empty set it to the current time.
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
|
||||
// Always store the time as UTC.
|
||||
now = now.UTC()
|
||||
|
||||
// Postgres truncates times to milliseconds when storing. We and do the same
|
||||
// here so the value we return is consistent with what we store.
|
||||
now = now.Truncate(time.Millisecond)
|
||||
|
||||
// Build the update SQL statement.
|
||||
query := sqlbuilder.NewUpdateBuilder()
|
||||
query.Update(userTableName)
|
||||
query.Set(
|
||||
query.Assign("archived_at", nil),
|
||||
)
|
||||
query.Where(query.Equal("id", req.ID))
|
||||
|
||||
// Execute the query with the provided context.
|
||||
sql, args := query.Build()
|
||||
sql = dbConn.Rebind(sql)
|
||||
_, err = dbConn.ExecContext(ctx, sql, args...)
|
||||
if err != nil {
|
||||
err = errors.Wrapf(err, "query - %s", query.String())
|
||||
err = errors.WithMessagef(err, "unarchive user %s failed", req.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a user from the database.
|
||||
func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID string) error {
|
||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.Delete")
|
||||
@ -682,3 +808,206 @@ func Delete(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, userID str
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPassword sends en email to the user to allow them to reset their password.
|
||||
func ResetPassword(ctx context.Context, dbConn *sqlx.DB, resetUrl func(string) string, notify notify.Email, req UserResetPasswordRequest, secretKey string, now time.Time) (string, error) {
|
||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.ResetPassword")
|
||||
defer span.Finish()
|
||||
|
||||
v := webcontext.Validator()
|
||||
|
||||
// Validate the request.
|
||||
err := v.StructCtx(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find user by email address.
|
||||
var u *User
|
||||
{
|
||||
query := selectQuery()
|
||||
query.Where(query.Equal("email", req.Email))
|
||||
|
||||
res, err := find(ctx, auth.Claims{}, dbConn, query, []interface{}{}, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if res == nil || len(res) == 0 {
|
||||
err = errors.WithMessagef(ErrNotFound, "No user found using '%s'.", req.Email)
|
||||
return "", err
|
||||
}
|
||||
u = res[0]
|
||||
}
|
||||
|
||||
// Update the user with a random string used to confirm the password reset.
|
||||
resetId := uuid.NewRandom().String()
|
||||
{
|
||||
// Always store the time as UTC.
|
||||
now = now.UTC()
|
||||
|
||||
// Postgres truncates times to milliseconds when storing. We and do the same
|
||||
// here so the value we return is consistent with what we store.
|
||||
now = now.Truncate(time.Millisecond)
|
||||
|
||||
// Build the update SQL statement.
|
||||
query := sqlbuilder.NewUpdateBuilder()
|
||||
query.Update(userTableName)
|
||||
query.Set(
|
||||
query.Assign("password_reset", resetId),
|
||||
query.Assign("updated_at", now),
|
||||
)
|
||||
query.Where(query.Equal("id", u.ID))
|
||||
|
||||
// Execute the query with the provided context.
|
||||
sql, args := query.Build()
|
||||
sql = dbConn.Rebind(sql)
|
||||
_, err = dbConn.ExecContext(ctx, sql, args...)
|
||||
if err != nil {
|
||||
err = errors.Wrapf(err, "query - %s", query.String())
|
||||
err = errors.WithMessagef(err, "Update user %s failed.", u.ID)
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if req.TTL.Seconds() == 0 {
|
||||
req.TTL = time.Minute * 90
|
||||
}
|
||||
|
||||
// Load the current IP makings the request.
|
||||
var requestIp string
|
||||
if vals, _ := webcontext.ContextValues(ctx); vals != nil {
|
||||
requestIp = vals.RequestIP
|
||||
}
|
||||
|
||||
// Generate a string that embeds additional information.
|
||||
hashPts := []string{
|
||||
resetId,
|
||||
strconv.Itoa(int(now.UTC().Unix())),
|
||||
strconv.Itoa(int(now.UTC().Add(req.TTL).Unix())),
|
||||
requestIp,
|
||||
}
|
||||
hashStr := strings.Join(hashPts, "|")
|
||||
|
||||
// This returns the nonce appended with the encrypted string for "hello world".
|
||||
crypto, err := symcrypto.New(secretKey)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(hashStr)
|
||||
if err != nil {
|
||||
return "", errors.WithStack(err)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Name": u.FirstName,
|
||||
"Url": resetUrl(encrypted),
|
||||
"Minutes": req.TTL.Minutes(),
|
||||
}
|
||||
|
||||
err = notify.Send(ctx, u.Email, "Reset your Password", "user_reset_password", data)
|
||||
if err != nil {
|
||||
err = errors.WithMessagef(err, "Send password reset email to %s failed.", u.Email)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return encrypted, nil
|
||||
}
|
||||
|
||||
// ResetConfirm updates the password for a user using the provided reset password ID.
|
||||
func ResetConfirm(ctx context.Context, dbConn *sqlx.DB, req UserResetConfirmRequest, secretKey string, now time.Time) (*User, error) {
|
||||
span, ctx := tracer.StartSpanFromContext(ctx, "internal.user.ResetConfirm")
|
||||
defer span.Finish()
|
||||
|
||||
v := webcontext.Validator()
|
||||
|
||||
// Validate the request.
|
||||
err := v.StructCtx(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
crypto, err := symcrypto.New(secretKey)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
hashStr, err := crypto.Decrypt(req.ResetHash)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
hashPts := strings.Split(hashStr, "|")
|
||||
|
||||
var hash ResetHash
|
||||
if len(hashPts) == 4 {
|
||||
hash.ResetID = hashPts[0]
|
||||
hash.CreatedAt, _ = strconv.Atoi(hashPts[1])
|
||||
hash.ExpiresAt, _ = strconv.Atoi(hashPts[2])
|
||||
hash.RequestIP = hashPts[3]
|
||||
}
|
||||
|
||||
// Validate the hash.
|
||||
err = v.StructCtx(ctx, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if int64(hash.ExpiresAt) < now.UTC().Unix() {
|
||||
err = errors.WithMessage(ErrResetExpired, "Password reset has expired.")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find user by password_reset.
|
||||
var u *User
|
||||
{
|
||||
query := selectQuery()
|
||||
query.Where(query.Equal("password_reset", hash.ResetID))
|
||||
|
||||
res, err := find(ctx, auth.Claims{}, dbConn, query, []interface{}{}, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if res == nil || len(res) == 0 {
|
||||
err = errors.WithMessage(ErrNotFound, "Invalid password reset.")
|
||||
return nil, err
|
||||
}
|
||||
u = res[0]
|
||||
}
|
||||
|
||||
// Save the new password for the user.
|
||||
{
|
||||
// Always store the time as UTC.
|
||||
now = now.UTC()
|
||||
|
||||
// Postgres truncates times to milliseconds when storing. We and do the same
|
||||
// here so the value we return is consistent with what we store.
|
||||
now = now.Truncate(time.Millisecond)
|
||||
|
||||
// Generate new password hash for the provided password.
|
||||
passwordSalt := uuid.NewRandom()
|
||||
saltedPassword := req.Password + passwordSalt.String()
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(saltedPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "generating password hash")
|
||||
}
|
||||
|
||||
// Build the update SQL statement.
|
||||
query := sqlbuilder.NewUpdateBuilder()
|
||||
query.Update(userTableName)
|
||||
query.Set(
|
||||
query.Assign("password_reset", nil),
|
||||
query.Assign("password_hash", passwordHash),
|
||||
query.Assign("password_salt", passwordSalt),
|
||||
query.Assign("updated_at", now),
|
||||
)
|
||||
query.Where(query.Equal("id", u.ID))
|
||||
|
||||
// Execute the query with the provided context.
|
||||
sql, args := query.Build()
|
||||
sql = dbConn.Rebind(sql)
|
||||
_, err = dbConn.ExecContext(ctx, sql, args...)
|
||||
if err != nil {
|
||||
err = errors.Wrapf(err, "query - %s", query.String())
|
||||
err = errors.WithMessagef(err, "update password for user %s failed", u.ID)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/notify"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/tests"
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@ -583,7 +584,7 @@ func TestUpdatePassword(t *testing.T) {
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tCreate failed.", tests.Failed)
|
||||
t.Fatalf("\t%s\tUpdate password failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tUpdatePassword ok.", tests.Success)
|
||||
|
||||
@ -886,6 +887,22 @@ func TestCrud(t *testing.T) {
|
||||
}
|
||||
t.Logf("\t%s\tArchive ok.", tests.Success)
|
||||
|
||||
// Unarchive (un-delete) the user.
|
||||
err = Unarchive(ctx, tt.claims(user, accountId), test.MasterDB, UserUnarchiveRequest{ID: user.ID}, now)
|
||||
if err != nil && errors.Cause(err) != tt.updateErr {
|
||||
t.Logf("\t\tGot : %+v", err)
|
||||
t.Logf("\t\tWant: %+v", tt.updateErr)
|
||||
t.Fatalf("\t%s\tUnarchive failed.", tests.Failed)
|
||||
} else if tt.updateErr == nil {
|
||||
// Trying to find the archived user with the includeArchived false should result no error.
|
||||
_, err = Read(ctx, tt.claims(user, accountId), test.MasterDB, user.ID, false)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tUnarchive Read failed.", tests.Failed)
|
||||
}
|
||||
}
|
||||
t.Logf("\t%s\tUnarchive ok.", tests.Success)
|
||||
|
||||
// Delete (hard-delete) the user.
|
||||
err = Delete(ctx, tt.claims(user, accountId), test.MasterDB, user.ID)
|
||||
if err != nil && errors.Cause(err) != tt.updateErr {
|
||||
@ -1053,6 +1070,177 @@ func TestFind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResetPassword validates that reset password for a user works.
|
||||
func TestResetPassword(t *testing.T) {
|
||||
|
||||
t.Log("Given the need ensure a user can reset their password.")
|
||||
{
|
||||
ctx := tests.Context()
|
||||
|
||||
now := time.Date(2018, time.October, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
tknGen := &MockTokenGenerator{}
|
||||
|
||||
// Create a new user for testing.
|
||||
initPass := uuid.NewRandom().String()
|
||||
user, err := Create(ctx, auth.Claims{}, test.MasterDB, UserCreateRequest{
|
||||
FirstName: "Lee",
|
||||
LastName: "Brown",
|
||||
Email: uuid.NewRandom().String() + "@geeksinthewoods.com",
|
||||
Password: initPass,
|
||||
PasswordConfirm: initPass,
|
||||
}, now)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tCreate failed.", tests.Failed)
|
||||
}
|
||||
|
||||
// Create a new random account.
|
||||
accountId := uuid.NewRandom().String()
|
||||
err = mockAccount(accountId, user.CreatedAt)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tCreate account failed.", tests.Failed)
|
||||
}
|
||||
|
||||
// Associate new random account with user.
|
||||
err = mockUserAccount(user.ID, accountId, user.CreatedAt, auth.RoleUser)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tCreate user account failed.", tests.Failed)
|
||||
}
|
||||
|
||||
// Mock the methods needed to make a password reset.
|
||||
resetUrl := func(string) string {
|
||||
return ""
|
||||
}
|
||||
notify := ¬ify.MockEmail{}
|
||||
|
||||
secretKey := "6368616e676520746869732070617373"
|
||||
|
||||
// Ensure validation is working by trying ResetPassword with an empty request.
|
||||
{
|
||||
expectedErr := errors.New("Key: 'UserResetPasswordRequest.email' Error:Field validation for 'email' failed on the 'required' tag")
|
||||
_, err = ResetPassword(ctx, test.MasterDB, resetUrl, notify, UserResetPasswordRequest{}, secretKey, now)
|
||||
if err == nil {
|
||||
t.Logf("\t\tWant: %+v", expectedErr)
|
||||
t.Fatalf("\t%s\tResetPassword failed.", tests.Failed)
|
||||
}
|
||||
|
||||
errStr := strings.Replace(err.Error(), "{{", "", -1)
|
||||
errStr = strings.Replace(errStr, "}}", "", -1)
|
||||
|
||||
if errStr != expectedErr.Error() {
|
||||
t.Logf("\t\tGot : %+v", errStr)
|
||||
t.Logf("\t\tWant: %+v", expectedErr)
|
||||
t.Fatalf("\t%s\tResetPassword Validation failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tResetPassword Validation ok.", tests.Success)
|
||||
}
|
||||
|
||||
ttl := time.Hour
|
||||
|
||||
// Make the reset password request.
|
||||
resetHash, err := ResetPassword(ctx, test.MasterDB, resetUrl, notify, UserResetPasswordRequest{
|
||||
Email: user.Email,
|
||||
TTL: ttl,
|
||||
}, secretKey, now)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tResetPassword failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tResetPassword ok.", tests.Success)
|
||||
|
||||
// Read the user to ensure the password_reset field was set.
|
||||
user, err = Read(ctx, auth.Claims{}, test.MasterDB, user.ID, false)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tRead failed.", tests.Failed)
|
||||
} else if user.PasswordReset == nil || user.PasswordReset.String == "" {
|
||||
t.Fatalf("\t%s\tUser field password_reset is empty.", tests.Failed)
|
||||
}
|
||||
|
||||
// Ensure validation is working by trying ResetConfirm with an empty request.
|
||||
{
|
||||
expectedErr := errors.New("Key: 'UserResetConfirmRequest.reset_hash' Error:Field validation for 'reset_hash' failed on the 'required' tag\n" +
|
||||
"Key: 'UserResetConfirmRequest.password' Error:Field validation for 'password' failed on the 'required' tag\n" +
|
||||
"Key: 'UserResetConfirmRequest.password_confirm' Error:Field validation for 'password_confirm' failed on the 'required' tag")
|
||||
_, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{}, secretKey, now)
|
||||
if err == nil {
|
||||
t.Logf("\t\tWant: %+v", expectedErr)
|
||||
t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed)
|
||||
}
|
||||
|
||||
errStr := strings.Replace(err.Error(), "{{", "", -1)
|
||||
errStr = strings.Replace(errStr, "}}", "", -1)
|
||||
|
||||
if errStr != expectedErr.Error() {
|
||||
t.Logf("\t\tGot : %+v", errStr)
|
||||
t.Logf("\t\tWant: %+v", expectedErr)
|
||||
t.Fatalf("\t%s\tResetConfirm Validation failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tResetConfirm Validation ok.", tests.Success)
|
||||
}
|
||||
|
||||
// Ensure the TTL is enforced.
|
||||
{
|
||||
newPass := uuid.NewRandom().String()
|
||||
_, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{
|
||||
ResetHash: resetHash,
|
||||
Password: newPass,
|
||||
PasswordConfirm: newPass,
|
||||
}, secretKey, now.UTC().Add(ttl*2))
|
||||
if errors.Cause(err) != ErrResetExpired {
|
||||
t.Logf("\t\tGot : %+v", errors.Cause(err))
|
||||
t.Logf("\t\tWant: %+v", ErrResetExpired)
|
||||
t.Fatalf("\t%s\tResetConfirm enforce TTL failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tResetConfirm enforce TTL ok.", tests.Success)
|
||||
}
|
||||
|
||||
// Assuming we have received the email and clicked the link, we now can ensure confirm works.
|
||||
newPass := uuid.NewRandom().String()
|
||||
reset, err := ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{
|
||||
ResetHash: resetHash,
|
||||
Password: newPass,
|
||||
PasswordConfirm: newPass,
|
||||
}, secretKey, now)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed)
|
||||
} else if reset.ID != user.ID {
|
||||
t.Logf("\t\tGot : %+v", reset.ID)
|
||||
t.Logf("\t\tWant: %+v", user.ID)
|
||||
t.Fatalf("\t%s\tResetConfirm failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tResetConfirm ok.", tests.Success)
|
||||
|
||||
// Verify that the user can be authenticated with the updated password.
|
||||
_, err = Authenticate(ctx, test.MasterDB, tknGen, user.Email, newPass, time.Hour, now)
|
||||
if err != nil {
|
||||
t.Log("\t\tGot :", err)
|
||||
t.Fatalf("\t%s\tAuthenticate failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tAuthenticate ok.", tests.Success)
|
||||
|
||||
// Ensure the reset hash does not work after its used.
|
||||
{
|
||||
newPass := uuid.NewRandom().String()
|
||||
_, err = ResetConfirm(ctx, test.MasterDB, UserResetConfirmRequest{
|
||||
ResetHash: resetHash,
|
||||
Password: newPass,
|
||||
PasswordConfirm: newPass,
|
||||
}, secretKey, now)
|
||||
if errors.Cause(err) != ErrNotFound {
|
||||
t.Logf("\t\tGot : %+v", errors.Cause(err))
|
||||
t.Logf("\t\tWant: %+v", ErrNotFound)
|
||||
t.Fatalf("\t%s\tResetConfirm enforce TTL failed.", tests.Failed)
|
||||
}
|
||||
t.Logf("\t%s\tResetConfirm reuse disabled ok.", tests.Success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mockUserAccount(userId, accountId string, now time.Time, roles ...string) error {
|
||||
var roleArr pq.StringArray
|
||||
for _, r := range roles {
|
||||
|
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 (
|
||||
"context"
|
||||
"database/sql"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/account"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
||||
"time"
|
||||
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/account"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/auth"
|
||||
"geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pborman/uuid"
|
||||
@ -26,6 +26,9 @@ var (
|
||||
// The database table for UserAccount
|
||||
const userAccountTableName = "users_accounts"
|
||||
|
||||
// The database table for User
|
||||
const userTableName = "users"
|
||||
|
||||
// The list of columns needed for mapRowsToUserAccount
|
||||
var userAccountMapColumns = "id,user_id,account_id,roles,status,created_at,updated_at,archived_at"
|
||||
|
||||
|
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",
|
||||
"ecs:UpdateService",
|
||||
"ses:SendEmail",
|
||||
"ses:ListIdentities",
|
||||
"secretsmanager:ListSecretVersionIds",
|
||||
"secretsmanager:GetSecretValue",
|
||||
"secretsmanager:CreateSecret",
|
||||
|
Reference in New Issue
Block a user