diff --git a/README.md b/README.md index f7abc02..f5a3af1 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,7 @@ schema migrations before running any unit tests. To login to the local Postgres container, use the following command: ```bash docker exec -it saas-starter-kit_postgres_1 /bin/bash -bash-4.4# psql -u postgres shared +bash-4.4# psql -U postgres shared ``` The example project currently only includes a few tables. As more functionality is built into both the web-app and diff --git a/cmd/web-api/handlers/routes.go b/cmd/web-api/handlers/routes.go index 84527c2..df88209 100644 --- a/cmd/web-api/handlers/routes.go +++ b/cmd/web-api/handlers/routes.go @@ -1,6 +1,7 @@ package handlers import ( + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "log" "net/http" "os" @@ -15,7 +16,7 @@ import ( ) // API returns a handler for a set of routes. -func API(shutdown chan os.Signal, log *log.Logger, env web.Env, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, globalMids ...web.Middleware) http.Handler { +func API(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, masterDB *sqlx.DB, redis *redis.Client, authenticator *auth.Authenticator, globalMids ...web.Middleware) http.Handler { // Define base middlewares applied to all requests. middlewares := []web.Middleware{ @@ -43,14 +44,14 @@ func API(shutdown chan os.Signal, log *log.Logger, env web.Env, masterDB *sqlx.D MasterDB: masterDB, TokenGenerator: authenticator, } - app.Handle("GET", "/v1/users", u.Find, mid.Authenticate(authenticator)) - app.Handle("POST", "/v1/users", u.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/v1/users/:id", u.Read, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/users", u.Update, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/users/password", u.UpdatePassword, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/users/archive", u.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/users/switch-account/:account_id", u.SwitchAccount, mid.Authenticate(authenticator)) + app.Handle("GET", "/v1/users", u.Find, mid.AuthenticateHeader(authenticator)) + app.Handle("POST", "/v1/users", u.Create, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/users/:id", u.Read, mid.AuthenticateHeader(authenticator)) + app.Handle("PATCH", "/v1/users", u.Update, mid.AuthenticateHeader(authenticator)) + app.Handle("PATCH", "/v1/users/password", u.UpdatePassword, mid.AuthenticateHeader(authenticator)) + app.Handle("PATCH", "/v1/users/archive", u.Archive, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("DELETE", "/v1/users/:id", u.Delete, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/users/switch-account/:account_id", u.SwitchAccount, mid.AuthenticateHeader(authenticator)) // This route is not authenticated app.Handle("POST", "/v1/oauth/token", u.Token) @@ -59,19 +60,19 @@ func API(shutdown chan os.Signal, log *log.Logger, env web.Env, masterDB *sqlx.D ua := UserAccount{ MasterDB: masterDB, } - app.Handle("GET", "/v1/user_accounts", ua.Find, mid.Authenticate(authenticator)) - app.Handle("POST", "/v1/user_accounts", ua.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/v1/user_accounts/:id", ua.Read, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/user_accounts", ua.Update, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/user_accounts/archive", ua.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("DELETE", "/v1/user_accounts", ua.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/user_accounts", ua.Find, mid.AuthenticateHeader(authenticator)) + app.Handle("POST", "/v1/user_accounts", ua.Create, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/user_accounts/:id", ua.Read, mid.AuthenticateHeader(authenticator)) + app.Handle("PATCH", "/v1/user_accounts", ua.Update, mid.AuthenticateHeader(authenticator)) + app.Handle("PATCH", "/v1/user_accounts/archive", ua.Archive, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("DELETE", "/v1/user_accounts", ua.Delete, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) // Register account endpoints. a := Account{ MasterDB: masterDB, } - app.Handle("GET", "/v1/accounts/:id", a.Read, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/accounts", a.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/accounts/:id", a.Read, mid.AuthenticateHeader(authenticator)) + app.Handle("PATCH", "/v1/accounts", a.Update, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) // Register signup endpoints. s := Signup{ @@ -83,12 +84,12 @@ func API(shutdown chan os.Signal, log *log.Logger, env web.Env, masterDB *sqlx.D p := Project{ MasterDB: masterDB, } - app.Handle("GET", "/v1/projects", p.Find, mid.Authenticate(authenticator)) - app.Handle("POST", "/v1/projects", p.Create, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("GET", "/v1/projects/:id", p.Read, mid.Authenticate(authenticator)) - app.Handle("PATCH", "/v1/projects", p.Update, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("PATCH", "/v1/projects/archive", p.Archive, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) - app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.Authenticate(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/projects", p.Find, mid.AuthenticateHeader(authenticator)) + app.Handle("POST", "/v1/projects", p.Create, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("GET", "/v1/projects/:id", p.Read, mid.AuthenticateHeader(authenticator)) + app.Handle("PATCH", "/v1/projects", p.Update, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("PATCH", "/v1/projects/archive", p.Archive, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) + app.Handle("DELETE", "/v1/projects/:id", p.Delete, mid.AuthenticateHeader(authenticator), mid.HasRole(auth.RoleAdmin)) // Register swagger documentation. // TODO: Add authentication. Current authenticator requires an Authorization header diff --git a/cmd/web-api/main.go b/cmd/web-api/main.go index 05febd9..d3b67aa 100644 --- a/cmd/web-api/main.go +++ b/cmd/web-api/main.go @@ -91,6 +91,7 @@ func main() { HostNames []string `envconfig:"HOST_NAMES" example:"alternative-subdomain.eproc.tech"` EnableHTTPS bool `default:"false" envconfig:"ENABLE_HTTPS"` TemplateDir string `default:"./templates" envconfig:"TEMPLATE_DIR"` + WebAppBaseUrl string `default:"http://127.0.0.1:3000" envconfig:"WEB_APP_BASE_URL" example:"www.eproc.tech"` DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"` ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` } diff --git a/cmd/web-app/handlers/root.go b/cmd/web-app/handlers/root.go index 366702f..58c3373 100644 --- a/cmd/web-app/handlers/root.go +++ b/cmd/web-app/handlers/root.go @@ -2,32 +2,63 @@ package handlers import ( "context" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "fmt" "net/http" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" "github.com/jmoiron/sqlx" ) -// User represents the User API method handler set. +// Root represents the Root API method handler set. type Root struct { - MasterDB *sqlx.DB - Renderer web.Renderer - // ADD OTHER STATE LIKE THE LOGGER AND CONFIG HERE. + MasterDB *sqlx.DB + Renderer web.Renderer + ProjectRoutes project_routes.ProjectRoutes } -// List returns all the existing users in the system. -func (u *Root) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +// Index determines if the user has authentication and loads the associated page. +func (h *Root) Index(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - // Force users to login to access the index page. - if claims, err := auth.ClaimsFromContext(ctx); err != nil || !claims.HasAuth() { - http.Redirect(w, r, "/user/login", http.StatusFound) - return nil + if claims, err := auth.ClaimsFromContext(ctx); err == nil && claims.HasAuth() { + return h.indexDashboard(ctx, w, r, params) } + return h.indexDefault(ctx, w, r, params) +} + +// indexDashboard loads the dashboard for a user when they are authenticated. +func (h *Root) indexDashboard(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { data := map[string]interface{}{ "imgSizes": []int{100, 200, 300, 400, 500}, } - return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "root-index.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) + return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "root-dashboard.gohtml", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) +} + +// indexDefault loads the root index page when a user has no authentication. +func (u *Root) indexDefault(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + http.Redirect(w, r, "/user/login", http.StatusFound) + return nil +} + +// IndexHtml redirects /index.html to the website root page. +func (u *Root) IndexHtml(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + http.Redirect(w, r, "/", http.StatusMovedPermanently) + return nil +} + +// RobotHandler returns a robots.txt response. +func (h *Root) RobotTxt(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + if webcontext.ContextEnv(ctx) != webcontext.Env_Prod { + txt := "User-agent: *\nDisallow: /" + return web.RespondText(ctx, w, txt, http.StatusOK) + } + + sitemapUrl := h.ProjectRoutes.WebAppUrl("/sitemap.xml") + + txt := fmt.Sprintf("User-agent: *\nDisallow: /ping\nDisallow: /status\nDisallow: /debug/\nSitemap: %s", sitemapUrl) + return web.RespondText(ctx, w, txt, http.StatusOK) } diff --git a/cmd/web-app/handlers/routes.go b/cmd/web-app/handlers/routes.go index 93c00e3..3c9876e 100644 --- a/cmd/web-app/handlers/routes.go +++ b/cmd/web-app/handlers/routes.go @@ -12,6 +12,7 @@ import ( "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" "github.com/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/contrib/go-redis/redis" ) @@ -22,7 +23,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, 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, renderer web.Renderer, globalMids ...web.Middleware) http.Handler { // Define base middlewares applied to all requests. middlewares := []web.Middleware{ @@ -42,7 +43,7 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir MasterDB: masterDB, Renderer: renderer, } - app.Handle("GET", "/projects", p.Index, mid.HasAuth()) + app.Handle("GET", "/projects", p.Index, mid.AuthenticateSessionRequired(authenticator), mid.HasAuth()) // Register user management and authentication endpoints. u := User{ @@ -69,12 +70,14 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir // Register root r := Root{ - MasterDB: masterDB, - Renderer: renderer, + MasterDB: masterDB, + Renderer: renderer, + ProjectRoutes: projectRoutes, } // This route is not authenticated - app.Handle("GET", "/index.html", r.Index) - app.Handle("GET", "/", r.Index) + app.Handle("GET", "/", r.Index, mid.AuthenticateSessionOptional(authenticator)) + app.Handle("GET", "/index.html", r.IndexHtml) + app.Handle("GET", "/robots.txt", r.RobotTxt) // Register health check endpoint. This route is not authenticated. check := Check{ @@ -84,6 +87,7 @@ func APP(shutdown chan os.Signal, log *log.Logger, env webcontext.Env, staticDir } app.Handle("GET", "/v1/health", check.Health) + // Handle static files/pages. Render a custom 404 page when file not found. static := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { err := web.StaticHandler(ctx, w, r, params, staticDir, "") if err != nil { diff --git a/cmd/web-app/handlers/signup.go b/cmd/web-app/handlers/signup.go index b25cf7f..802ff83 100644 --- a/cmd/web-app/handlers/signup.go +++ b/cmd/web-app/handlers/signup.go @@ -27,6 +27,11 @@ type Signup struct { // Step1 handles collecting the first detailed needed to create a new account. func (h *Signup) Step1(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(signup.SignupRequest) data := make(map[string]interface{}) @@ -46,7 +51,7 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque } // Execute the account / user signup. - res, err := signup.Signup(ctx, claims, h.MasterDB, *req, time.Now()) + _, err = signup.Signup(ctx, claims, h.MasterDB, *req, ctxValues.Now) if err != nil { switch errors.Cause(err) { case account.ErrForbidden: @@ -54,21 +59,27 @@ func (h *Signup) Step1(ctx context.Context, w http.ResponseWriter, r *http.Reque default: if verr, ok := weberror.NewValidationError(ctx, err); ok { data["validationErrors"] = verr.(*weberror.Error) + return nil } else { return err } } - } else { - // Authenticated the new user. - userAuth, err := user.Authenticate(ctx, h.MasterDB, h.Authenticator, res.User.Email, req.User.Password, time.Hour, time.Now()) - if err != nil { - return err - } - - _ = userAuth.Expiry - _ = userAuth.AccessToken } + // Authenticated the new user. + token, err := user.Authenticate(ctx, h.MasterDB, h.Authenticator, req.User.Email, req.User.Password, time.Hour, ctxValues.Now) + if err != nil { + 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) } return nil diff --git a/cmd/web-app/handlers/user.go b/cmd/web-app/handlers/user.go index 1d385a7..d2adb37 100644 --- a/cmd/web-app/handlers/user.go +++ b/cmd/web-app/handlers/user.go @@ -2,11 +2,20 @@ package handlers import ( "context" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "net/http" + "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" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + "geeks-accelerator/oss/saas-starter-kit/internal/user" + "github.com/gorilla/schema" + "github.com/gorilla/sessions" "github.com/jmoiron/sqlx" + "github.com/pborman/uuid" + "github.com/pkg/errors" ) // User represents the User API method handler set. @@ -16,20 +25,138 @@ type User struct { Authenticator *auth.Authenticator } -// List returns all the existing users in the system. -func (u *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - - return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-login.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil) +type UserLoginRequest struct { + user.AuthenticateRequest + RememberMe bool } // List returns all the existing users in the system. -func (u *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) Login(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-logout.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil) + ctxValues, err := webcontext.ContextValues(ctx) + if err != nil { + return err + } + + // + req := new(UserLoginRequest) + 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 + } + } + + sessionTTL := time.Hour + if req.RememberMe { + sessionTTL = time.Hour * 36 + } + + // Authenticated the user. + token, err := user.Authenticate(ctx, h.MasterDB, h.Authenticator, req.Email, req.Password, sessionTTL, 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) + } + + 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(UserLoginRequest{})); ok { + data["validationDefaults"] = verr.(*weberror.Error) + } + + return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-login.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, data) +} + +// handleSessionToken persists the access token to the session for request authentication. +func handleSessionToken(ctx context.Context, w http.ResponseWriter, r *http.Request, token user.Token) error { + if token.AccessToken == "" { + return errors.New("accessToken is required.") + } + + sess := webcontext.ContextSession(ctx) + + if sess.IsNew { + sess.ID = uuid.NewRandom().String() + } + + sess.Options = &sessions.Options{ + Path: "/", + MaxAge: int(token.TTL.Seconds()), + HttpOnly: false, + } + + sess = webcontext.SessionWithAccessToken(sess, token.AccessToken) + + if err := sess.Save(r, w); err != nil { + return err + } + + return nil +} + +// Logout handles removing authentication for the user. +func (h *User) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + + sess := webcontext.ContextSession(ctx) + + // Set the access token to empty to logout the user. + sess = webcontext.SessionWithAccessToken(sess, "") + + if err := sess.Save(r, w); err != nil { + return err + } + + // Redirect the user to the root page. + http.Redirect(w, r, "/", http.StatusFound) + + return nil } // List returns all the existing users in the system. -func (u *User) ForgotPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { +func (h *User) ForgotPassword(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - return u.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-forgot-password.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil) + return h.Renderer.Render(ctx, w, r, tmplLayoutBase, "user-forgot-password.tmpl", web.MIMETextHTMLCharsetUTF8, http.StatusOK, nil) } diff --git a/cmd/web-app/main.go b/cmd/web-app/main.go index b31368c..87fb116 100644 --- a/cmd/web-app/main.go +++ b/cmd/web-app/main.go @@ -6,9 +6,6 @@ import ( "encoding/json" "expvar" "fmt" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "html/template" "log" "net" @@ -25,18 +22,25 @@ import ( "geeks-accelerator/oss/saas-starter-kit/cmd/web-app/handlers" "geeks-accelerator/oss/saas-starter-kit/internal/mid" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/devops" "geeks-accelerator/oss/saas-starter-kit/internal/platform/flag" img_resize "geeks-accelerator/oss/saas-starter-kit/internal/platform/img-resize" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" template_renderer "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/template-renderer" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" + project_routes "geeks-accelerator/oss/saas-starter-kit/internal/project-routes" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/ec2metadata" "github.com/aws/aws-sdk-go/aws/session" "github.com/go-redis/redis" + "github.com/gorilla/securecookie" + "github.com/gorilla/sessions" "github.com/kelseyhightower/envconfig" "github.com/lib/pq" + "github.com/pkg/errors" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" awstrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/aws/aws-sdk-go/aws" @@ -90,6 +94,9 @@ func main() { CloudFrontEnabled bool `envconfig:"CLOUDFRONT_ENABLED"` ImgResizeEnabled bool `envconfig:"IMG_RESIZE_ENABLED"` } + 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"` DebugHost string `default:"0.0.0.0:4000" envconfig:"DEBUG_HOST"` ShutdownTimeout time.Duration `default:"5s" envconfig:"SHUTDOWN_TIMEOUT"` } @@ -382,8 +389,50 @@ func main() { serviceMiddlewares = append(serviceMiddlewares, redirect) } + // Init session store + if cfg.Service.SessionName == "" { + cfg.Service.SessionName = fmt.Sprintf("%s-session", cfg.Service.Name) + } + + // Set the session key if not provided in the config. + if cfg.Service.SessionKey == "" { + + // AWS secrets manager ID for storing the session key. This is optional and only will be used + // if a valid AWS session is provided. + secretID := filepath.Join(cfg.Aws.SecretsManagerConfigPrefix, "session") + + // If AWS is enabled, check the Secrets Manager for the session key. + if awsSession != nil { + cfg.Service.SessionKey, err = devops.SecretManagerGetString(awsSession, secretID) + if err != nil && errors.Cause(err) != devops.ErrSecreteNotFound { + log.Fatalf("main : Session : %+v", err) + } + } + + // If the session key is still empty, generate a new key. + if cfg.Service.SessionKey == "" { + cfg.Service.SessionKey = string(securecookie.GenerateRandomKey(32)) + + if awsSession != nil { + err = devops.SecretManagerPutString(awsSession, secretID, cfg.Service.SessionKey) + if err != nil { + log.Fatalf("main : Session : %+v", err) + } + } + } + } + + // Generate the new session store and append it to the global list of middlewares. + sessionStore := sessions.NewCookieStore([]byte(cfg.Service.SessionKey)) + serviceMiddlewares = append(serviceMiddlewares, mid.Session(sessionStore, cfg.Service.SessionName)) + // ========================================================================= // URL Formatter + projectRoutes, err := project_routes.New(cfg.Service.WebApiBaseUrl, cfg.Service.BaseUrl) + if err != nil { + log.Fatalf("main : project routes : %+v", cfg.Service.BaseUrl, err) + } + // s3UrlFormatter is a help function used by to convert an s3 key to // a publicly available image URL. var staticS3UrlFormatter func(string) string @@ -402,15 +451,7 @@ func main() { return s3UrlFormatter(p) } } else { - baseUrl, err := url.Parse(cfg.Service.BaseUrl) - if err != nil { - log.Fatalf("main : url Parse(%s) : %+v", cfg.Service.BaseUrl, err) - } - - staticS3UrlFormatter = func(p string) string { - baseUrl.Path = p - return baseUrl.String() - } + staticS3UrlFormatter = projectRoutes.WebAppUrl } // staticUrlFormatter is a help function used by template functions defined below. @@ -735,7 +776,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, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, renderer, serviceMiddlewares...), ReadTimeout: cfg.HTTP.ReadTimeout, WriteTimeout: cfg.HTTP.WriteTimeout, MaxHeaderBytes: 1 << 20, @@ -752,7 +793,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, renderer, serviceMiddlewares...), + Handler: handlers.APP(shutdown, log, cfg.Env, cfg.Service.StaticFiles.Dir, cfg.Service.TemplateDir, masterDb, redisClient, authenticator, projectRoutes, renderer, serviceMiddlewares...), ReadTimeout: cfg.HTTPS.ReadTimeout, WriteTimeout: cfg.HTTPS.WriteTimeout, MaxHeaderBytes: 1 << 20, diff --git a/cmd/web-app/templates/content/root-index.tmpl b/cmd/web-app/templates/content/root-dashboard.gohtml similarity index 100% rename from cmd/web-app/templates/content/root-index.tmpl rename to cmd/web-app/templates/content/root-dashboard.gohtml diff --git a/cmd/web-app/templates/content/user-login.tmpl b/cmd/web-app/templates/content/user-login.tmpl index a3295c4..fa8546f 100644 --- a/cmd/web-app/templates/content/user-login.tmpl +++ b/cmd/web-app/templates/content/user-login.tmpl @@ -20,22 +20,24 @@

Welcome Back!

-
+
- + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "AuthenticateRequest.Email" }}
- + + {{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "AuthenticateRequest.Password" }}
- - + +
- +

diff --git a/cmd/web-app/templates/layouts/base.tmpl b/cmd/web-app/templates/layouts/base.tmpl index 7f949cf..a580be3 100644 --- a/cmd/web-app/templates/layouts/base.tmpl +++ b/cmd/web-app/templates/layouts/base.tmpl @@ -59,7 +59,7 @@ diff --git a/go.mod b/go.mod index f593d07..f8b338a 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,8 @@ require ( github.com/google/go-cmp v0.3.0 github.com/google/uuid v1.1.1 // indirect github.com/gorilla/schema v1.1.0 + github.com/gorilla/securecookie v1.1.1 + github.com/gorilla/sessions v1.2.0 github.com/huandu/go-sqlbuilder v1.4.1 github.com/iancoleman/strcase v0.0.0-20190422225806-e506e3ef7365 github.com/jmoiron/sqlx v1.2.0 diff --git a/go.sum b/go.sum index 7662375..8f34be9 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,9 @@ github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY= github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= +github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/go-sqlbuilder v1.4.1 h1:DYGFGLbOUXhtQ2kwO1uyDIPJbsztmVWdPPDyxi0EJGw= diff --git a/internal/mid/auth.go b/internal/mid/auth.go index caa6fa2..565e665 100644 --- a/internal/mid/auth.go +++ b/internal/mid/auth.go @@ -2,12 +2,13 @@ package mid import ( "context" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "net/http" "strings" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/weberror" "github.com/pkg/errors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) @@ -22,17 +23,18 @@ func ErrorForbidden(ctx context.Context) error { } // Authenticate validates a JWT from the `Authorization` header. -func Authenticate(authenticator *auth.Authenticator) web.Middleware { +func AuthenticateHeader(authenticator *auth.Authenticator) web.Middleware { // This is the actual middleware function to be executed. f := func(after web.Handler) web.Handler { // Wrap this handler around the next one provided. h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { - span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.Authenticate") + span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.AuthenticateHeader") defer span.Finish() m := func() error { + authHdr := r.Header.Get("Authorization") if authHdr == "" { err := errors.New("missing Authorization header") @@ -71,6 +73,65 @@ func Authenticate(authenticator *auth.Authenticator) web.Middleware { return f } +// AuthenticateSessionRequired requires a JWT access token to be loaded from the session. +func AuthenticateSessionRequired(authenticator *auth.Authenticator) web.Middleware { + return authenticateSession(authenticator, true) +} + +// AuthenticateSessionOptional loads a JWT access token from the session if it exists. +func AuthenticateSessionOptional(authenticator *auth.Authenticator) web.Middleware { + return authenticateSession(authenticator, false) +} + +// authenticateSession validates a JWT by the loading the access token from the session. +func authenticateSession(authenticator *auth.Authenticator, required bool) web.Middleware { + + // This is the actual middleware function to be executed. + f := func(after web.Handler) web.Handler { + + // Wrap this handler around the next one provided. + h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.AuthenticateSession") + defer span.Finish() + + m := func() error { + tknStr, ok := webcontext.ContextAccessToken(ctx) + if !ok || tknStr == "" { + if required { + err := errors.New("missing AccessToken from session") + return weberror.NewError(ctx, err, http.StatusUnauthorized) + } else { + return nil + } + } + + claims, err := authenticator.ParseClaims(tknStr) + if err != nil { + return weberror.NewError(ctx, err, http.StatusUnauthorized) + } + + // Add claims to the context so they can be retrieved later. + ctx = context.WithValue(ctx, auth.Key, claims) + + return nil + } + + if err := m(); err != nil { + if web.RequestIsJson(r) { + return web.RespondJsonError(ctx, w, err) + } + return err + } + + return after(ctx, w, r, params) + } + + return h + } + + return f +} + // HasAuth validates the current user is an authenticated user, func HasAuth() web.Middleware { diff --git a/internal/mid/session.go b/internal/mid/session.go new file mode 100644 index 0000000..a260c47 --- /dev/null +++ b/internal/mid/session.go @@ -0,0 +1,37 @@ +package mid + +import ( + "context" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "net/http" + + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web" + "github.com/gorilla/sessions" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) + +func Session(store sessions.Store, sessionName string) web.Middleware { + + // This is the actual middleware function to be executed. + f := func(after web.Handler) web.Handler { + + // Wrap this handler around the next one provided. + h := func(ctx context.Context, w http.ResponseWriter, r *http.Request, params map[string]string) error { + span, ctx := tracer.StartSpanFromContext(ctx, "internal.mid.Session") + defer span.Finish() + + // Get a session. We're ignoring the error resulted from decoding an + // existing session: Get() always returns a session, even if empty. + session, _ := store.Get(r, sessionName) + + // Append the session to the context. + ctx = webcontext.ContextWithSession(ctx, session) + + return after(ctx, w, r, params) + } + + return h + } + + return f +} diff --git a/internal/platform/devops/autocert.go b/internal/platform/devops/autocert.go index eb28c0b..22db25e 100644 --- a/internal/platform/devops/autocert.go +++ b/internal/platform/devops/autocert.go @@ -6,7 +6,6 @@ import ( "path/filepath" "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/secretsmanager" "github.com/pkg/errors" @@ -46,25 +45,20 @@ func (c *SecretManagerAutocertCache) Get(ctx context.Context, key string) ([]byt } } - svc := secretsmanager.New(c.awsSession) - secretID := filepath.Join(c.secretPrefix, key) // Load the secret by ID from Secrets Manager. - res, err := svc.GetSecretValue(&secretsmanager.GetSecretValueInput{ - SecretId: aws.String(secretID), - }) + res, err := SecretManagerGetString(c.awsSession, secretID) if err != nil { - if aerr, ok := err.(awserr.Error); ok && (aerr.Code() == secretsmanager.ErrCodeResourceNotFoundException || aerr.Code() == secretsmanager.ErrCodeInvalidRequestException) { + if err == ErrSecreteNotFound { return nil, autocert.ErrCacheMiss } - - return nil, errors.Wrapf(err, "failed to get value for secret id %s", secretID) + return nil, err } log.Printf("AWS Secrets Manager : Secret %s found", secretID) - return []byte(*res.SecretString), nil + return []byte(res), nil } // Put stores the data in the cache under the specified key. @@ -72,48 +66,15 @@ func (c *SecretManagerAutocertCache) Get(ctx context.Context, key string) ([]byt // as long as the reverse operation, Get, results in the original data. func (c *SecretManagerAutocertCache) Put(ctx context.Context, key string, data []byte) error { - svc := secretsmanager.New(c.awsSession) - secretID := filepath.Join(c.secretPrefix, key) - // Create the new entry in AWS Secret Manager for the file. - _, err := svc.CreateSecret(&secretsmanager.CreateSecretInput{ - Name: aws.String(secretID), - SecretString: aws.String(string(data)), - }) + err := SecretManagerPutString(c.awsSession, secretID, string(data)) if err != nil { - aerr, ok := err.(awserr.Error) - - if ok && aerr.Code() == secretsmanager.ErrCodeInvalidRequestException { - // InvalidRequestException: You can't create this secret because a secret with this - // name is already scheduled for deletion. - - // Restore secret after it was already previously deleted. - _, err = svc.RestoreSecret(&secretsmanager.RestoreSecretInput{ - SecretId: aws.String(secretID), - }) - if err != nil { - return errors.Wrapf(err, "autocert failed to restore secret %s", secretID) - } - - } else if !ok || aerr.Code() != secretsmanager.ErrCodeResourceExistsException { - return errors.Wrapf(err, "autocert failed to create secret %s", secretID) - } - - // If where was a resource exists error for create, then need to update the secret instead. - _, err = svc.UpdateSecret(&secretsmanager.UpdateSecretInput{ - SecretId: aws.String(secretID), - SecretString: aws.String(string(data)), - }) - if err != nil { - return errors.Wrapf(err, "autocert failed to update secret %s", secretID) - } - - log.Printf("AWS Secrets Manager : Secret %s updated", secretID) - } else { - log.Printf("AWS Secrets Manager : Secret %s created", secretID) + return err } + log.Printf("AWS Secrets Manager : Secret %s updated", secretID) + if c.cache != nil { err = c.cache.Put(ctx, key, data) if err != nil { diff --git a/internal/platform/devops/secrets_manager.go b/internal/platform/devops/secrets_manager.go new file mode 100644 index 0000000..2ccd1a6 --- /dev/null +++ b/internal/platform/devops/secrets_manager.go @@ -0,0 +1,76 @@ +package devops + +import ( + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/secretsmanager" + "github.com/pkg/errors" +) + +var ErrSecreteNotFound = errors.New("secret not found") + +// SecretManagerGetString loads a key from AWS Secrets Manager. +// when UnrecognizedClientException its likely the AWS IAM permissions are not correct. +func SecretManagerGetString(awsSession *session.Session, secretID string) (string, error) { + + svc := secretsmanager.New(awsSession) + + // Load the secret by ID from Secrets Manager. + res, err := svc.GetSecretValue(&secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretID), + }) + if err != nil { + if aerr, ok := err.(awserr.Error); ok && (aerr.Code() == secretsmanager.ErrCodeResourceNotFoundException || aerr.Code() == secretsmanager.ErrCodeInvalidRequestException) { + return "", ErrSecreteNotFound + } + + return "", errors.Wrapf(err, "failed to get value for secret id %s", secretID) + } + + return *res.SecretString, nil +} + +// SecretManagerPutString saves a value to AWS Secrets Manager. +// If the secret ID does not exist, it will create it. +// If the secret ID was deleted, it will restore it and then update the value. +func SecretManagerPutString(awsSession *session.Session, secretID, value string) error { + + svc := secretsmanager.New(awsSession) + + // Create the new entry in AWS Secret Manager for the file. + _, err := svc.CreateSecret(&secretsmanager.CreateSecretInput{ + Name: aws.String(secretID), + SecretString: aws.String(value), + }) + if err != nil { + aerr, ok := err.(awserr.Error) + + if ok && aerr.Code() == secretsmanager.ErrCodeInvalidRequestException { + // InvalidRequestException: You can't create this secret because a secret with this + // name is already scheduled for deletion. + + // Restore secret after it was already previously deleted. + _, err = svc.RestoreSecret(&secretsmanager.RestoreSecretInput{ + SecretId: aws.String(secretID), + }) + if err != nil { + return errors.Wrapf(err, "failed to restore secret %s", secretID) + } + + } else if !ok || aerr.Code() != secretsmanager.ErrCodeResourceExistsException { + return errors.Wrapf(err, "failed to create secret %s", secretID) + } + + // If where was a resource exists error for create, then need to update the secret instead. + _, err = svc.UpdateSecret(&secretsmanager.UpdateSecretInput{ + SecretId: aws.String(secretID), + SecretString: aws.String(value), + }) + if err != nil { + return errors.Wrapf(err, "failed to update secret %s", secretID) + } + } + + return nil +} diff --git a/internal/platform/web/response.go b/internal/platform/web/response.go index 05447ab..90c3c15 100644 --- a/internal/platform/web/response.go +++ b/internal/platform/web/response.go @@ -96,7 +96,7 @@ func RespondJson(ctx context.Context, w http.ResponseWriter, data interface{}, s // RespondError sends an error back to the client as plain text with // the status code 500 Internal Service Error func RespondError(ctx context.Context, w http.ResponseWriter, er error) error { - return RespondErrorStatus(ctx, w, er, 0) + return RespondErrorStatus(ctx, w, er, http.StatusInternalServerError) } // RespondErrorStatus sends an error back to the client as plain text with diff --git a/internal/platform/web/template-renderer/template_renderer.go b/internal/platform/web/template-renderer/template_renderer.go index b2d1592..1b2a00b 100644 --- a/internal/platform/web/template-renderer/template_renderer.go +++ b/internal/platform/web/template-renderer/template_renderer.go @@ -218,7 +218,7 @@ func NewTemplateRenderer(templateDir string, enableHotReload bool, globalViewDat // Main template used to render execute all templates against. r.mainTemplate = template.New("main") - r.mainTemplate, _ = r.mainTemplate.Parse(`{{define "main" }} {{ template "base" . }} {{ end }}`) + r.mainTemplate, _ = r.mainTemplate.Parse(`{{define "main" }}{{ template "base" . }}{{ end }}`) r.mainTemplate.Funcs(tmpl.Funcs) // Ensure all layout files render successfully with no errors. diff --git a/internal/platform/web/webcontext/context.go b/internal/platform/web/webcontext/context.go index 6d4f413..a9d39b3 100644 --- a/internal/platform/web/webcontext/context.go +++ b/internal/platform/web/webcontext/context.go @@ -8,10 +8,10 @@ import ( ) // ctxKey represents the type of value for the context key. -type ctxKey int +type ctxKeyValues int // KeyValues is how request values or stored/retrieved. -const KeyValues ctxKey = 1 +const KeyValues ctxKeyValues = 1 var ErrContextRequired = errors.New("web value missing from context") diff --git a/internal/platform/web/webcontext/session.go b/internal/platform/web/webcontext/session.go new file mode 100644 index 0000000..b3c89ae --- /dev/null +++ b/internal/platform/web/webcontext/session.go @@ -0,0 +1,48 @@ +package webcontext + +import ( + "context" + + "github.com/gorilla/sessions" +) + +// ctxKeySession represents the type of value for the context key. +type ctxKeySession int + +// KeySession is used to store/retrieve a Session from a context.Context. +const KeySession ctxKeySession = 1 + +// ContextWithSession appends a universal translator to a context. +func ContextWithSession(ctx context.Context, session *sessions.Session) context.Context { + return context.WithValue(ctx, KeySession, session) +} + +// ContextSession returns the session from a context. +func ContextSession(ctx context.Context) *sessions.Session { + return ctx.Value(KeySession).(*sessions.Session) +} + +func ContextAccessToken(ctx context.Context) (string, bool) { + session := ContextSession(ctx) + + return SessionAccessToken(session) +} + +func SessionAccessToken(session *sessions.Session) (string, bool) { + if sv, ok := session.Values["AccessToken"].(string); ok { + return sv, true + } + + return "", false +} + +func SessionWithAccessToken(session *sessions.Session, accessToken string) *sessions.Session { + + if accessToken != "" { + session.Values["AccessToken"] = accessToken + } else { + delete(session.Values, "AccessToken") + } + + return session +} diff --git a/internal/project-routes/project_routes.go b/internal/project-routes/project_routes.go new file mode 100644 index 0000000..0786969 --- /dev/null +++ b/internal/project-routes/project_routes.go @@ -0,0 +1,41 @@ +package project_routes + +import ( + "github.com/pkg/errors" + "net/url" +) + +type ProjectRoutes struct { + webAppUrl url.URL + webApiUrl url.URL +} + +func New(apiBaseUrl, appBaseUrl string) (ProjectRoutes, error) { + var r ProjectRoutes + + apiUrl, err := url.Parse(apiBaseUrl) + if err != nil { + return r, errors.WithMessagef(err, "Failed to parse api base URL '%s'", apiBaseUrl) + } + r.webApiUrl = *apiUrl + + appUrl, err := url.Parse(appBaseUrl) + if err != nil { + return r, errors.WithMessagef(err, "Failed to parse app base URL '%s'", appBaseUrl) + } + r.webAppUrl = *appUrl + + return r, nil +} + +func (r ProjectRoutes) WebAppUrl(urlPath string) string { + u := r.webAppUrl + u.Path = urlPath + return u.String() +} + +func (r ProjectRoutes) WebApiUrl(urlPath string) string { + u := r.webApiUrl + u.Path = urlPath + return u.String() +} diff --git a/internal/signup/signup.go b/internal/signup/signup.go index e1e43a2..5e3acdc 100644 --- a/internal/signup/signup.go +++ b/internal/signup/signup.go @@ -2,6 +2,9 @@ package signup import ( "context" + "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/web/webcontext" @@ -10,7 +13,6 @@ import ( "github.com/jmoiron/sqlx" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" "gopkg.in/go-playground/validator.v9" - "time" ) // Signup performs the steps needed to create a new account, new user and then associate @@ -36,12 +38,15 @@ func Signup(ctx context.Context, claims auth.Claims, dbConn *sqlx.DB, req Signup return false } + fieldName := strings.Trim(fl.FieldName(), "{}") + var uniq bool - switch fl.FieldName() { + switch fieldName { case "Name", "name": uniq = uniqName case "Email", "email": uniq = uniqEmail + } return uniq diff --git a/internal/user/auth.go b/internal/user/auth.go index 77d9c11..19742c2 100644 --- a/internal/user/auth.go +++ b/internal/user/auth.go @@ -4,12 +4,12 @@ import ( "context" "crypto/rsa" "database/sql" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" - "github.com/dgrijalva/jwt-go" "strings" "time" "geeks-accelerator/oss/saas-starter-kit/internal/platform/auth" + "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" + "github.com/dgrijalva/jwt-go" "github.com/huandu/go-sqlbuilder" "github.com/jmoiron/sqlx" "github.com/lib/pq" @@ -291,6 +291,7 @@ func generateToken(ctx context.Context, dbConn *sqlx.DB, tknGen TokenGenerator, if expires.Seconds() > 0 { tkn.Expiry = now.Add(expires) + tkn.TTL = expires } return tkn, nil diff --git a/internal/user/models.go b/internal/user/models.go index 1914d85..d613708 100644 --- a/internal/user/models.go +++ b/internal/user/models.go @@ -110,6 +110,12 @@ type UserFindRequest struct { IncludedArchived bool `json:"included-archived" example:"false"` } +// 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"` + Password string `json:"password" validate:"required" example:"NeverTellSecret"` +} + // Token is the payload we deliver to users when they authenticate. type Token struct { // AccessToken is the token that authorizes and authenticates @@ -123,7 +129,8 @@ type Token struct { // If zero, TokenSource implementations will reuse the same // token forever and RefreshToken or equivalent // mechanisms for that TokenSource will not be used. - Expiry time.Time `json:"expiry,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` + TTL time.Duration `json:"ttl,omitempty"` // contains filtered or unexported fields claims auth.Claims `json:"-"` } diff --git a/internal/user/user.go b/internal/user/user.go index 0f78ca3..eee12a7 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -3,10 +3,10 @@ package user import ( "context" "database/sql" - "geeks-accelerator/oss/saas-starter-kit/internal/platform/web/webcontext" "time" "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"