1
0
mirror of https://github.com/raseels-repos/golang-saas-starter-kit.git synced 2025-07-01 00:55:01 +02:00

Completed user login with session authentication

This commit is contained in:
Lee Brown
2019-07-31 18:34:27 -08:00
parent 227af02f31
commit e81e4690af
26 changed files with 603 additions and 143 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,22 +20,24 @@
<div class="text-center">
<h1 class="h4 text-gray-900 mb-4">Welcome Back!</h1>
</div>
<form class="user">
<form class="user" method="post" novalidate>
<div class="form-group">
<input type="email" class="form-control form-control-user" id="loginEmail" aria-describedby="emailHelp" placeholder="Enter Email Address...">
<input type="email" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "AuthenticateRequest.Email" }}" name="Email" value="{{ $.form.Email }}" placeholder="Enter Email Address...">
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "AuthenticateRequest.Email" }}
</div>
<div class="form-group">
<input type="password" class="form-control form-control-user" id="loginPassword" placeholder="Password">
<input type="password" class="form-control form-control-user {{ ValidationFieldClass $.validationErrors "AuthenticateRequest.Password" }}" name="Password" value="{{ $.form.Password }}" placeholder="Password">
{{template "invalid-feedback" dict "validationDefaults" $.validationDefaults "validationErrors" $.validationErrors "fieldName" "AuthenticateRequest.Password" }}
</div>
<div class="form-group">
<div class="custom-control custom-checkbox small">
<input type="checkbox" class="custom-control-input" id="customCheck">
<label class="custom-control-label" for="customCheck">Remember Me</label>
<input type="checkbox" class="custom-control-input" id="checkRemberMe" name="RememberMe" value="1" {{ if $.form.RememberMe }}checked="checked"{{end}}>
<label class="custom-control-label" for="checkRemberMe">Remember Me</label>
</div>
</div>
<a href="index.html" class="btn btn-primary btn-user btn-block">
<button class="btn btn-primary btn-user btn-block">
Login
</a>
</button>
<hr>
</form>
<hr>

View File

@ -59,7 +59,7 @@
<div class="modal-body">Select "Logout" below if you are ready to end your current session.</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
<a class="btn btn-primary" href="/logout">Logout</a>
<a class="btn btn-primary" href="/user/logout">Logout</a>
</div>
</div>
</div>

2
go.mod
View File

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

3
go.sum
View File

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

View File

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

37
internal/mid/session.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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