From 93d83104913cf0b4bb24a9f78286496dca7e0771 Mon Sep 17 00:00:00 2001 From: dhax Date: Mon, 25 Sep 2017 18:23:11 +0200 Subject: [PATCH] initial commit --- .gitignore | 15 + README.md | 37 +++ api/admin/accounts.go | 172 ++++++++++++ api/admin/api.go | 52 ++++ api/admin/errors.go | 81 ++++++ api/api.go | 113 ++++++++ api/app/account.go | 225 +++++++++++++++ api/app/api.go | 33 +++ api/app/errors.go | 81 ++++++ api/server.go | 65 +++++ auth/api.go | 91 +++++++ auth/authenticator.go | 87 ++++++ auth/authorizer.go | 31 +++ auth/crypto.go | 20 ++ auth/errors.go | 64 +++++ auth/handler.go | 215 +++++++++++++++ auth/handler_test.go | 346 ++++++++++++++++++++++++ auth/jwt.go | 118 ++++++++ auth/logintoken.go | 89 ++++++ cmd/migrate.go | 60 ++++ cmd/root.go | 94 +++++++ cmd/serve.go | 58 ++++ database/accountStore.go | 66 +++++ database/admAccountStore.go | 84 ++++++ database/authStore.go | 84 ++++++ database/migrate/1_initial.go | 65 +++++ database/migrate/2_bootstrap_users.go | 48 ++++ database/migrate/3_add_profile_table.go | 53 ++++ database/migrate/main.go | 70 +++++ database/postgres.go | 44 +++ email/auth.go | 24 ++ email/email.go | 148 ++++++++++ logging/logger.go | 121 +++++++++ main.go | 21 ++ models/account.go | 86 ++++++ models/profile.go | 42 +++ models/token.go | 33 +++ templates/email/auth/loginToken.html | 12 + templates/email/footer.html | 11 + templates/email/header.html | 15 + templates/email/styles.html | 39 +++ testing/mock/authStore.go | 61 +++++ testing/mock/emailService.go | 13 + testing/rest/admin/accounts.http | 44 +++ testing/rest/user/account.http | 33 +++ testing/rest/user/profile.http | 15 + 46 files changed, 3379 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/admin/accounts.go create mode 100644 api/admin/api.go create mode 100644 api/admin/errors.go create mode 100644 api/api.go create mode 100644 api/app/account.go create mode 100644 api/app/api.go create mode 100644 api/app/errors.go create mode 100644 api/server.go create mode 100644 auth/api.go create mode 100644 auth/authenticator.go create mode 100644 auth/authorizer.go create mode 100644 auth/crypto.go create mode 100644 auth/errors.go create mode 100644 auth/handler.go create mode 100644 auth/handler_test.go create mode 100644 auth/jwt.go create mode 100644 auth/logintoken.go create mode 100644 cmd/migrate.go create mode 100644 cmd/root.go create mode 100644 cmd/serve.go create mode 100644 database/accountStore.go create mode 100644 database/admAccountStore.go create mode 100644 database/authStore.go create mode 100644 database/migrate/1_initial.go create mode 100644 database/migrate/2_bootstrap_users.go create mode 100644 database/migrate/3_add_profile_table.go create mode 100644 database/migrate/main.go create mode 100644 database/postgres.go create mode 100644 email/auth.go create mode 100644 email/email.go create mode 100644 logging/logger.go create mode 100644 main.go create mode 100644 models/account.go create mode 100644 models/profile.go create mode 100644 models/token.go create mode 100644 templates/email/auth/loginToken.html create mode 100644 templates/email/footer.html create mode 100644 templates/email/header.html create mode 100644 templates/email/styles.html create mode 100644 testing/mock/authStore.go create mode 100644 testing/mock/emailService.go create mode 100644 testing/rest/admin/accounts.http create mode 100644 testing/rest/user/account.http create mode 100644 testing/rest/user/profile.http diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fe74b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_STORE +.vscode +.realize + +# Binaries for programs and plugins +*.exe +*.dll +*.so +*.dylib + +# Test binary, build with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..77357ac --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ + +## Go Restful API Boilerplate + +Easily extendible RESTful API boilerplate aiming to follow idiomatic go and best practice. + +### Features +* PostgreSQL support including migrations using [go-pg](https://github.com/go-pg/pg) +* Structured logging with [Logrus](https://github.com/sirupsen/logrus) +* Routing with [chi router](https://github.com/go-chi/chi) +* JWT Authentication using [jwt-go](https://github.com/dgrijalva/jwt-go) with passwordless email authentication (could be easily extended to use passwords instead) +* Configuration using [viper](https://github.com/spf13/viper) +* CLI features using [cobra](https://github.com/spf13/cobra) +* [dep](https://github.com/golang/dep) for dependency management + + + +### Environment Variables + +Name | Type | Default | Description +---|---|---|--- +PORT | int | 3000 | http port +LOG_LEVEL | string | debug | log level +LOG_TEXTLOGGING | bool | false | defaults to json logging +DATABASE_URL | string | postgres://postgres:postgres@localhost:5432/gobase?sslmode=disable | PostgreSQL connection string +AUTH_LOGIN_URL | string | http://localhost:3000/login | client login url as sent in login token email +AUTH_LOGIN_TOKEN_LENGTH | int | 8 | length of login token +AUTH_LOGIN_TOKEN_EXPIRY | int | 11 | login token expiry in minutes +AUTH_JWT_SECRET | string | random | jwt sign and verify key - value "random" sets random 32 char secret at startup +AUTH_JWT_EXPIRY | int | 15 | jwt access token expiry in minutes +AUTH_JWT_REFRESH_EXPIRY | int | 60 | jwt refresh token expiry in minutes +EMAIL_SMTP_HOST | string || email smtp host +EMAIL_SMTP_PORT | int || email smtp port +EMAIL_SMTP_USER | string || email smtp username +EMAIL_SMTP_PASSWORD | string || email smtp password +EMAIL_FROM_ADDRESS | string || from address used in sending emails +EMAIL_FROM_NAME | string || from name used in sending emails + diff --git a/api/admin/accounts.go b/api/admin/accounts.go new file mode 100644 index 0000000..967c3df --- /dev/null +++ b/api/admin/accounts.go @@ -0,0 +1,172 @@ +package admin + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/go-ozzo/ozzo-validation" + + "github.com/dhax/go-base/models" + "github.com/go-chi/chi" + "github.com/go-chi/render" +) + +// The list of error types returned from account resource. +var ( + ErrAccountValidation = errors.New("account validation error") +) + +type ctxKey int + +const ( + ctxAccount ctxKey = iota +) + +// AccountStore defines database operations for account management. +type AccountStore interface { + List(f models.AccountFilter) (*[]models.Account, int, error) + Create(*models.Account) error + Get(id int) (*models.Account, error) + Update(*models.Account) error + Delete(*models.Account) error +} + +// AccountResource implements account managment handler. +type AccountResource struct { + Store AccountStore +} + +// NewAccountResource creates and returns an account resource. +func NewAccountResource(store AccountStore) *AccountResource { + return &AccountResource{ + Store: store, + } +} + +func (rs *AccountResource) router() *chi.Mux { + r := chi.NewRouter() + r.Get("/", rs.list) + r.Post("/", rs.create) + r.Route("/{accountID}", func(r chi.Router) { + r.Use(rs.accountCtx) + r.Get("/", rs.get) + r.Put("/", rs.update) + r.Delete("/", rs.delete) + }) + return r +} + +func (rs *AccountResource) accountCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "accountID")) + if err != nil { + render.Render(w, r, ErrBadRequest) + return + } + account, err := rs.Store.Get(id) + if err != nil { + render.Render(w, r, ErrNotFound) + return + } + ctx := context.WithValue(r.Context(), ctxAccount, account) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +type accountRequest struct { + *models.Account +} + +func (d *accountRequest) Bind(r *http.Request) error { + return nil +} + +type accountResponse struct { + *models.Account +} + +func newAccountResponse(a *models.Account) *accountResponse { + resp := &accountResponse{Account: a} + return resp +} + +type accountListResponse struct { + Accounts *[]models.Account `json:"accounts"` + Count int `json:"count"` +} + +func newAccountListResponse(a *[]models.Account, count int) *accountListResponse { + resp := &accountListResponse{ + Accounts: a, + Count: count, + } + return resp +} + +func (rs *AccountResource) list(w http.ResponseWriter, r *http.Request) { + f := models.NewAccountFilter(r.URL.Query()) + al, count, err := rs.Store.List(f) + if err != nil { + render.Render(w, r, ErrRender(err)) + return + } + render.Respond(w, r, newAccountListResponse(al, count)) +} + +func (rs *AccountResource) create(w http.ResponseWriter, r *http.Request) { + data := &accountRequest{} + if err := render.Bind(r, data); err != nil { + render.Respond(w, r, ErrInvalidRequest(err)) + return + } + + acc := data.Account + if err := rs.Store.Create(acc); err != nil { + switch err.(type) { + case validation.Errors: + render.Render(w, r, ErrValidation(ErrAccountValidation, err)) + return + } + render.Render(w, r, ErrInvalidRequest(err)) + return + } + render.Respond(w, r, newAccountResponse(acc)) +} + +func (rs *AccountResource) get(w http.ResponseWriter, r *http.Request) { + acc := r.Context().Value(ctxAccount).(*models.Account) + render.Respond(w, r, newAccountResponse(acc)) +} + +func (rs *AccountResource) update(w http.ResponseWriter, r *http.Request) { + acc := r.Context().Value(ctxAccount).(*models.Account) + data := &accountRequest{Account: acc} + if err := render.Bind(r, data); err != nil { + render.Render(w, r, ErrInvalidRequest(err)) + return + } + + acc = data.Account + if err := rs.Store.Update(acc); err != nil { + switch err.(type) { + case validation.Errors: + render.Render(w, r, ErrValidation(ErrAccountValidation, err)) + return + } + render.Render(w, r, ErrInvalidRequest(err)) + return + } + + render.Respond(w, r, newAccountResponse(acc)) +} + +func (rs *AccountResource) delete(w http.ResponseWriter, r *http.Request) { + acc := r.Context().Value(ctxAccount).(*models.Account) + if err := rs.Store.Delete(acc); err != nil { + render.Render(w, r, ErrInvalidRequest(err)) + return + } + render.Respond(w, r, http.NoBody) +} diff --git a/api/admin/api.go b/api/admin/api.go new file mode 100644 index 0000000..d2dbf5d --- /dev/null +++ b/api/admin/api.go @@ -0,0 +1,52 @@ +package admin + +import ( + "net/http" + + "github.com/sirupsen/logrus" + + "github.com/go-chi/chi" + "github.com/go-pg/pg" + + "github.com/dhax/go-base/auth" + "github.com/dhax/go-base/database" + "github.com/dhax/go-base/logging" +) + +const ( + roleAdmin = "admin" +) + +// API provides admin application resources and handlers. +type API struct { + Accounts *AccountResource +} + +// NewAPI configures and returns admin application API. +func NewAPI(db *pg.DB) (*API, error) { + + accountStore := database.NewAdmAccountStore(db) + accounts := NewAccountResource(accountStore) + + api := &API{ + Accounts: accounts, + } + return api, nil +} + +// Router provides admin application routes. +func (a *API) Router() *chi.Mux { + r := chi.NewRouter() + r.Use(auth.RequiresRole(roleAdmin)) + + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello Admin")) + }) + + r.Mount("/accounts", a.Accounts.router()) + return r +} + +func log(r *http.Request) logrus.FieldLogger { + return logging.GetLogEntry(r) +} diff --git a/api/admin/errors.go b/api/admin/errors.go new file mode 100644 index 0000000..d239dc8 --- /dev/null +++ b/api/admin/errors.go @@ -0,0 +1,81 @@ +package admin + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/render" +) + +// ErrResponse renderer type for handling all sorts of errors. +type ErrResponse struct { + Err error `json:"-"` // low-level runtime error + HTTPStatusCode int `json:"-"` // http response status code + + StatusText string `json:"status"` // user-level status message + AppCode int64 `json:"code,omitempty"` // application-specific error code + ErrorText string `json:"error,omitempty"` // application-level error message, for debugging +} + +// Render sets the application-specific error code in AppCode. +func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { + render.Status(r, e.HTTPStatusCode) + return nil +} + +// ErrInvalidRequest returns status 422 Unprocessable Entity including error message. +func ErrInvalidRequest(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: http.StatusText(http.StatusUnprocessableEntity), + ErrorText: err.Error(), + } +} + +// ErrRender returns status 422 Unprocessable Entity rendering response error. +func ErrRender(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: "Error rendering response.", + ErrorText: err.Error(), + } +} + +// ErrValidationResponse renderer for handling validation errors. +type ErrValidationResponse struct { + *ErrResponse + Errors string `json:"errors,omitempty"` +} + +// Render sets the application-specific error code in AppCode. +func (ev *ErrValidationResponse) Render(w http.ResponseWriter, r *http.Request) error { + render.Status(r, ev.ErrResponse.HTTPStatusCode) + return nil +} + +// ErrValidation returns status 422 Unprocessable Entity stating validation errors. +func ErrValidation(err error, valErrors error) render.Renderer { + b, _ := json.Marshal(valErrors) + return &ErrValidationResponse{ + &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: http.StatusText(http.StatusUnprocessableEntity), + ErrorText: err.Error(), + }, + string(b), + } +} + +var ( + // ErrBadRequest return status 400 Bad Request for malformed request body. + ErrBadRequest = &ErrResponse{HTTPStatusCode: http.StatusBadRequest, StatusText: http.StatusText(http.StatusBadRequest)} + + // ErrNotFound returns status 404 Not Found for invalid resource request. + ErrNotFound = &ErrResponse{HTTPStatusCode: http.StatusNotFound, StatusText: http.StatusText(http.StatusNotFound)} + + // ErrInternalServerError returns status 500 Internal Server Error. + ErrInternalServerError = &ErrResponse{HTTPStatusCode: http.StatusInternalServerError, StatusText: http.StatusText(http.StatusInternalServerError)} +) diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..8a9fda5 --- /dev/null +++ b/api/api.go @@ -0,0 +1,113 @@ +package api + +import ( + "net/http" + "path" + "strings" + "time" + + "github.com/dhax/go-base/api/admin" + "github.com/dhax/go-base/api/app" + "github.com/dhax/go-base/auth" + "github.com/dhax/go-base/database" + "github.com/dhax/go-base/email" + "github.com/dhax/go-base/logging" + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/go-chi/cors" + "github.com/go-chi/render" +) + +// NewAPI configures application resources and routes +func NewAPI() (*chi.Mux, error) { + logger := logging.NewLogger() + + db, err := database.DBConn() + if err != nil { + return nil, err + } + + emailService, err := email.NewEmailService() + if err != nil { + return nil, err + } + + authStore := database.NewAuthStore(db) + authResource, err := auth.NewResource(authStore, emailService) + if err != nil { + return nil, err + } + + adminAPI, err := admin.NewAPI(db) + if err != nil { + return nil, err + } + + appAPI, err := app.NewAPI(db) + if err != nil { + return nil, err + } + + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + // r.Use(middleware.RealIP) + r.Use(middleware.DefaultCompress) + r.Use(middleware.Timeout(15 * time.Second)) + + r.Use(logging.NewStructuredLogger(logger)) + r.Use(render.SetContentType(render.ContentTypeJSON)) + + // use CORS middleware if client is not served by this api, e.g. from other domain or CDN + // r.Use(corsConfig().Handler) + + r.Mount("/auth", authResource.Router()) + r.Group(func(r chi.Router) { + r.Use(authResource.Token.Verifier()) + r.Use(auth.Authenticator) + r.Mount("/admin", adminAPI.Router()) + r.Mount("/api", appAPI.Router()) + }) + + r.Get("/ping", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("pong")) + }) + + client := "./public" + r.Get("/*", SPAHandler(client)) + + return r, nil +} + +func corsConfig() *cors.Cors { + // Basic CORS + // for more ideas, see: https://developer.github.com/v3/#cross-origin-resource-sharing + return cors.New(cors.Options{ + // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts + AllowedOrigins: []string{"*"}, + // AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + ExposedHeaders: []string{"Link"}, + AllowCredentials: true, + MaxAge: 86400, // Maximum value not ignored by any of major browsers + }) +} + +// SPAHandler serves the public Single Page Application +func SPAHandler(publicDir string) http.HandlerFunc { + handler := http.FileServer(http.Dir(publicDir)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + url := r.URL.String() + + // serve static files + if strings.Contains(url, ".") || url == "/" { + handler.ServeHTTP(w, r) + return + } + + // otherwise always serve index.html + http.ServeFile(w, r, path.Join(publicDir, "/index.html")) + }) +} diff --git a/api/app/account.go b/api/app/account.go new file mode 100644 index 0000000..8e18988 --- /dev/null +++ b/api/app/account.go @@ -0,0 +1,225 @@ +package app + +import ( + "context" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi" + "github.com/go-chi/render" + validation "github.com/go-ozzo/ozzo-validation" + + "github.com/dhax/go-base/auth" + "github.com/dhax/go-base/logging" + "github.com/dhax/go-base/models" +) + +type ctxKey int + +const ( + ctxAccount ctxKey = iota +) + +// AccountStore defines database operations for account. +type AccountStore interface { + Get(id int) (*models.Account, error) + Update(*models.Account) error + Delete(*models.Account) error + UpdateToken(*models.Token) error + DeleteToken(*models.Token) error + UpdateProfile(*models.Profile) error +} + +// AccountResource implements account managment handler. +type AccountResource struct { + Store AccountStore +} + +// NewAccountResource creates and returns an account resource. +func NewAccountResource(store AccountStore) *AccountResource { + return &AccountResource{ + Store: store, + } +} + +func (rs *AccountResource) router() *chi.Mux { + r := chi.NewRouter() + r.Use(rs.accountCtx) + r.Get("/", rs.get) + r.Put("/", rs.update) + r.Delete("/", rs.delete) + r.Route("/token/{tokenID}", func(r chi.Router) { + r.Put("/", rs.updateToken) + r.Delete("/", rs.deleteToken) + }) + r.Put("/profile", rs.updateProfile) + return r +} + +func (rs *AccountResource) accountCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := auth.ClaimsFromCtx(r.Context()) + logging.GetLogEntry(r).WithField("account_id", claims.ID) + account, err := rs.Store.Get(claims.ID) + if err != nil { + // account deleted while access token still valid + logging.GetLogEntry(r).WithField("account", claims.Sub).Warn(err) + render.Render(w, r, ErrNotFound) + return + } + ctx := context.WithValue(r.Context(), ctxAccount, account) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +type accountRequest struct { + *models.Account + // not really neccessary here as we limit updated database columns in store + ProtectedID int `json:"id"` + ProtectedActive bool `json:"active"` + ProtectedRoles []string `json:"roles"` +} + +func (d *accountRequest) Bind(r *http.Request) error { + // d.ProtectedActive = true + // d.ProtectedRoles = []string{} + return nil +} + +type accountResponse struct { + *models.Account +} + +func newAccountResponse(a *models.Account) *accountResponse { + resp := &accountResponse{Account: a} + return resp +} + +func (rs *AccountResource) get(w http.ResponseWriter, r *http.Request) { + acc := r.Context().Value(ctxAccount).(*models.Account) + render.Respond(w, r, newAccountResponse(acc)) +} + +func (rs *AccountResource) update(w http.ResponseWriter, r *http.Request) { + acc := r.Context().Value(ctxAccount).(*models.Account) + data := &accountRequest{Account: acc} + if err := render.Bind(r, data); err != nil { + render.Render(w, r, ErrInvalidRequest(err)) + return + } + + acc = data.Account + if err := rs.Store.Update(acc); err != nil { + switch err.(type) { + case validation.Errors: + render.Render(w, r, ErrValidation(err)) + return + } + render.Render(w, r, ErrRender(err)) + return + } + + render.Respond(w, r, newAccountResponse(acc)) +} + +func (rs *AccountResource) delete(w http.ResponseWriter, r *http.Request) { + acc := r.Context().Value(ctxAccount).(*models.Account) + if err := rs.Store.Delete(acc); err != nil { + render.Render(w, r, ErrRender(err)) + return + } + render.Respond(w, r, http.NoBody) +} + +type tokenRequest struct { + Identifier string + ProtectedID int `json:"id"` +} + +func (d *tokenRequest) Bind(r *http.Request) error { + d.Identifier = strings.TrimSpace(d.Identifier) + return nil +} + +func (rs *AccountResource) updateToken(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "tokenID")) + if err != nil { + render.Respond(w, r, ErrBadRequest) + return + } + data := &tokenRequest{} + if err := render.Bind(r, data); err != nil { + render.Respond(w, r, ErrInvalidRequest(err)) + return + } + acc := r.Context().Value(ctxAccount).(*models.Account) + for _, t := range acc.Token { + if t.ID == id { + if err := rs.Store.UpdateToken(&models.Token{ + ID: t.ID, + Identifier: data.Identifier, + }); err != nil { + render.Respond(w, r, ErrInvalidRequest(err)) + return + } + } + } + render.Respond(w, r, http.NoBody) +} + +func (rs *AccountResource) deleteToken(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(chi.URLParam(r, "tokenID")) + if err != nil { + render.Respond(w, r, ErrBadRequest) + return + } + acc := r.Context().Value(ctxAccount).(*models.Account) + for _, t := range acc.Token { + if t.ID == id { + rs.Store.DeleteToken(&models.Token{ID: t.ID}) + } + } + render.Respond(w, r, http.NoBody) +} + +type profileRequest struct { + *models.Profile + ProtectedID int `json:"id"` +} + +func (d *profileRequest) Bind(r *http.Request) error { + // d.ProtectedActive = true + // d.ProtectedRoles = []string{} + return nil +} + +type profileResponse struct { + *models.Profile +} + +func newProfileResponse(p *models.Profile) *profileResponse { + return &profileResponse{ + Profile: p, + } +} + +func (rs *AccountResource) updateProfile(w http.ResponseWriter, r *http.Request) { + acc := r.Context().Value(ctxAccount).(*models.Account) + data := &profileRequest{Profile: acc.Profile} + if err := render.Bind(r, data); err != nil { + render.Render(w, r, ErrInvalidRequest(err)) + } + + p := data.Profile + if err := rs.Store.UpdateProfile(p); err != nil { + switch err.(type) { + case validation.Errors: + render.Render(w, r, ErrValidation(err)) + return + } + render.Render(w, r, ErrRender(err)) + return + } + render.Respond(w, r, newProfileResponse(p)) +} diff --git a/api/app/api.go b/api/app/api.go new file mode 100644 index 0000000..93f0cb8 --- /dev/null +++ b/api/app/api.go @@ -0,0 +1,33 @@ +package app + +import ( + "github.com/go-chi/chi" + "github.com/go-pg/pg" + + "github.com/dhax/go-base/database" +) + +// API provides application resources and handlers. +type API struct { + Account *AccountResource +} + +// NewAPI configures and returns application API. +func NewAPI(db *pg.DB) (*API, error) { + accountStore := database.NewAccountStore(db) + account := NewAccountResource(accountStore) + + api := &API{ + Account: account, + } + return api, nil +} + +// Router provides application routes. +func (a *API) Router() *chi.Mux { + r := chi.NewRouter() + + r.Mount("/account", a.Account.router()) + + return r +} diff --git a/api/app/errors.go b/api/app/errors.go new file mode 100644 index 0000000..e4d09f7 --- /dev/null +++ b/api/app/errors.go @@ -0,0 +1,81 @@ +package app + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/render" +) + +// ErrResponse renderer type for handling all sorts of errors. +type ErrResponse struct { + Err error `json:"-"` // low-level runtime error + HTTPStatusCode int `json:"-"` // http response status code + + StatusText string `json:"status"` // user-level status message + AppCode int64 `json:"code,omitempty"` // application-specific error code + ErrorText string `json:"error,omitempty"` // application-level error message, for debugging +} + +// Render sets the application-specific error code in AppCode. +func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { + render.Status(r, e.HTTPStatusCode) + return nil +} + +// ErrInvalidRequest returns status 422 Unprocessable Entity including error message. +func ErrInvalidRequest(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: http.StatusText(http.StatusUnprocessableEntity), + ErrorText: err.Error(), + } +} + +// ErrValidationResponse renderer for handling validation errors. +type ErrValidationResponse struct { + *ErrResponse + Errors string `json:"errors,omitempty"` +} + +// Render sets the application-specific error code in AppCode. +func (ev *ErrValidationResponse) Render(w http.ResponseWriter, r *http.Request) error { + render.Status(r, ev.ErrResponse.HTTPStatusCode) + return nil +} + +// ErrValidation returns status 422 Unprocessable Entity stating validation errors. +func ErrValidation(valErrors error) render.Renderer { + b, _ := json.Marshal(valErrors) + return &ErrValidationResponse{ + &ErrResponse{ + Err: nil, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: http.StatusText(http.StatusUnprocessableEntity), + ErrorText: "object validation error", + }, + string(b), + } +} + +// ErrRender returns status 422 Unprocessable Entity rendering response error. +func ErrRender(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: http.StatusText(http.StatusUnprocessableEntity), + ErrorText: err.Error(), + } +} + +var ( + // ErrBadRequest return status 400 Bad Request for malformed request body. + ErrBadRequest = &ErrResponse{HTTPStatusCode: http.StatusBadRequest, StatusText: http.StatusText(http.StatusBadRequest)} + + // ErrNotFound returns status 404 Not Found for invalid resource request. + ErrNotFound = &ErrResponse{HTTPStatusCode: http.StatusNotFound, StatusText: http.StatusText(http.StatusNotFound)} + + // ErrInternalServerError returns status 500 Internal Server Error. + ErrInternalServerError = &ErrResponse{HTTPStatusCode: http.StatusInternalServerError, StatusText: http.StatusText(http.StatusInternalServerError)} +) diff --git a/api/server.go b/api/server.go new file mode 100644 index 0000000..4bf2f60 --- /dev/null +++ b/api/server.go @@ -0,0 +1,65 @@ +package api + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "strings" + + "github.com/spf13/viper" +) + +// Server provides an http.Server +type Server struct { + *http.Server +} + +// NewServer creates and configures an APIServer serving all application routes. +func NewServer() (*Server, error) { + log.Println("configuring server...") + api, err := NewAPI() + if err != nil { + return nil, err + } + + var addr string + port := viper.GetString("port") + + // allow port to be set as localhost:3000 in env during development to avoid "accept incoming network connection" request on restarts + if strings.Contains(port, ":") { + addr = port + } else { + addr = ":" + port + } + + srv := http.Server{ + Addr: addr, + Handler: api, + } + + return &Server{&srv}, nil +} + +// Start runs ListenAndServe on the http.Server with graceful shutdown +func (srv *Server) Start() { + log.Println("starting server...") + go func() { + if err := srv.ListenAndServe(); err != nil { + panic(err) + } + }() + log.Printf("Listening on %s\n", srv.Addr) + + quit := make(chan os.Signal) + signal.Notify(quit, os.Interrupt) + sig := <-quit + log.Println("Shutting down server... Reason:", sig) + // teardown logic... + + if err := srv.Shutdown(context.Background()); err != nil { + panic(err) + } + log.Println("Server gracefully stopped") +} diff --git a/auth/api.go b/auth/api.go new file mode 100644 index 0000000..719a844 --- /dev/null +++ b/auth/api.go @@ -0,0 +1,91 @@ +package auth + +import ( + "net/http" + "time" + + "github.com/dhax/go-base/email" + "github.com/dhax/go-base/logging" + "github.com/dhax/go-base/models" + "github.com/go-chi/chi" + "github.com/go-chi/render" + "github.com/sirupsen/logrus" +) + +// Store defines database operations on account and token data. +type Store interface { + GetByID(id int) (*models.Account, error) + GetByEmail(email string) (*models.Account, error) + GetByRefreshToken(token string) (*models.Account, *models.Token, error) + UpdateAccount(a *models.Account) error + SaveRefreshToken(u *models.Token) error + DeleteRefreshToken(t *models.Token) error + PurgeExpiredToken() error +} + +// EmailService defines methods to send account emails. +type EmailService interface { + LoginToken(name, email string, c email.LoginTokenContent) error +} + +// Resource implements passwordless token authentication against a database. +type Resource struct { + Login *LoginTokenAuth + Token *TokenAuth + store Store + mailer EmailService +} + +// NewResource returns a configured authentication resource. +func NewResource(store Store, mailer EmailService) (*Resource, error) { + loginAuth, err := NewLoginTokenAuth() + if err != nil { + return nil, err + } + + tokenAuth, err := NewTokenAuth() + if err != nil { + return nil, err + } + + resource := &Resource{ + Login: loginAuth, + Token: tokenAuth, + store: store, + mailer: mailer, + } + + resource.Cleanup() + + return resource, nil +} + +// Router provides neccessary routes for passwordless authentication flow. +func (rs *Resource) Router() *chi.Mux { + r := chi.NewRouter() + r.Use(render.SetContentType(render.ContentTypeJSON)) + r.Post("/login", rs.login) + r.Post("/token", rs.token) + r.Group(func(r chi.Router) { + r.Use(rs.Token.Verifier()) + r.Use(AuthenticateRefreshJWT) + r.Post("/refresh", rs.refresh) + r.Post("/logout", rs.logout) + }) + return r +} + +func (rs *Resource) Cleanup() { + ticker := time.NewTicker(time.Hour * 1) + go func() { + for range ticker.C { + if err := rs.store.PurgeExpiredToken(); err != nil { + logging.Logger.WithField("auth", "cleanup").Error(err) + } + } + }() +} + +func log(r *http.Request) logrus.FieldLogger { + return logging.GetLogEntry(r) +} diff --git a/auth/authenticator.go b/auth/authenticator.go new file mode 100644 index 0000000..a2a0508 --- /dev/null +++ b/auth/authenticator.go @@ -0,0 +1,87 @@ +package auth + +import ( + "context" + "errors" + "net/http" + + "github.com/go-chi/jwtauth" + "github.com/go-chi/render" +) + +type ctxKey int + +const ( + ctxClaims ctxKey = iota + ctxRefreshToken +) + +var ( + errTokenUnauthorized = errors.New("token unauthorized") + errTokenExpired = errors.New("token expired") + errInvalidAccessToken = errors.New("invalid access token") + errInvalidRefreshToken = errors.New("invalid refresh token") +) + +// ClaimsFromCtx retrieves the parsed AppClaims from request context. +func ClaimsFromCtx(ctx context.Context) AppClaims { + return ctx.Value(ctxClaims).(AppClaims) +} + +// RefreshTokenFromCtx retrieves the parsed refresh token from context. +func RefreshTokenFromCtx(ctx context.Context) string { + return ctx.Value(ctxRefreshToken).(string) +} + +// Authenticator is a default authentication middleware to enforce access from the +// Verifier middleware request context values. The Authenticator sends a 401 Unauthorized +// response for any unverified tokens and passes the good ones through. +func Authenticator(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, claims, err := jwtauth.FromContext(r.Context()) + + if err != nil { + log(r).Warn(err) + render.Render(w, r, ErrUnauthorized(errTokenUnauthorized)) + return + } + + if !token.Valid { + render.Render(w, r, ErrUnauthorized(errTokenExpired)) + return + } + + // Token is authenticated, parse claims + pc, ok := parseClaims(claims) + if !ok { + render.Render(w, r, ErrUnauthorized(errInvalidAccessToken)) + return + } + ctx := context.WithValue(r.Context(), ctxClaims, pc) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// AuthenticateRefreshJWT checks validity of refresh tokens and is only used for access token refresh and logout requests. It responds with 401 Unauthorized for invalid or expired refresh tokens. +func AuthenticateRefreshJWT(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, claims, err := jwtauth.FromContext(r.Context()) + if err != nil { + log(r).Warn(err) + render.Render(w, r, ErrUnauthorized(errTokenUnauthorized)) + return + } + if !token.Valid { + render.Render(w, r, ErrUnauthorized(errTokenExpired)) + return + } + refreshToken, ok := parseRefreshClaims(claims) + if !ok { + render.Render(w, r, ErrUnauthorized(errInvalidRefreshToken)) + return + } + // Token is authenticated, set on context + ctx := context.WithValue(r.Context(), ctxRefreshToken, refreshToken) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/auth/authorizer.go b/auth/authorizer.go new file mode 100644 index 0000000..ada3de2 --- /dev/null +++ b/auth/authorizer.go @@ -0,0 +1,31 @@ +package auth + +import ( + "net/http" + + "github.com/go-chi/render" +) + +// RequiresRole middleware restricts access to accounts having role parameter in their jwt claims. +func RequiresRole(role string) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + hfn := func(w http.ResponseWriter, r *http.Request) { + claims := ClaimsFromCtx(r.Context()) + if !hasRole(role, claims.Roles) { + render.Render(w, r, ErrForbidden) + return + } + next.ServeHTTP(w, r) + } + return http.HandlerFunc(hfn) + } +} + +func hasRole(role string, roles []string) bool { + for _, r := range roles { + if r == role { + return true + } + } + return false +} diff --git a/auth/crypto.go b/auth/crypto.go new file mode 100644 index 0000000..c7054e9 --- /dev/null +++ b/auth/crypto.go @@ -0,0 +1,20 @@ +package auth + +import ( + "math/rand" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +func randStringBytes(n int) string { + b := make([]byte, n) + for i := range b { + b[i] = letterBytes[rand.Intn(len(letterBytes))] + } + return string(b) +} diff --git a/auth/errors.go b/auth/errors.go new file mode 100644 index 0000000..70d6263 --- /dev/null +++ b/auth/errors.go @@ -0,0 +1,64 @@ +package auth + +import ( + "net/http" + + "github.com/go-chi/render" +) + +// ErrResponse renderer type for handling all sorts of errors. +type ErrResponse struct { + Err error `json:"-"` // low-level runtime error + HTTPStatusCode int `json:"-"` // http response status code + + StatusText string `json:"status"` // user-level status message + AppCode int64 `json:"code,omitempty"` // application-specific error code + ErrorText string `json:"error,omitempty"` // application-level error message, for debugging +} + +// Render sets the application-specific error code in AppCode. +func (e *ErrResponse) Render(w http.ResponseWriter, r *http.Request) error { + render.Status(r, e.HTTPStatusCode) + return nil +} + +// ErrUnauthorized renders status 401 Unauthorized with custom error message. +func ErrUnauthorized(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnauthorized, + StatusText: http.StatusText(http.StatusUnauthorized), + ErrorText: err.Error(), + } +} + +// ErrRender returns status 422 Unprocessable Entity for invalid request body +func ErrRender(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: http.StatusText(http.StatusUnprocessableEntity), + ErrorText: err.Error(), + } +} + +// ErrInvalidRequest returns status 422 Unprocessable Entity with validation errors +func ErrInvalidRequest(err error) render.Renderer { + return &ErrResponse{ + Err: err, + HTTPStatusCode: http.StatusUnprocessableEntity, + StatusText: http.StatusText(http.StatusUnprocessableEntity), + ErrorText: err.Error(), + } +} + +// The list of default error types without specific error message. +var ( + ErrBadRequest = &ErrResponse{HTTPStatusCode: http.StatusBadRequest, StatusText: http.StatusText(http.StatusBadRequest)} + + ErrForbidden = &ErrResponse{HTTPStatusCode: http.StatusForbidden, StatusText: http.StatusText(http.StatusForbidden)} + + ErrNotFound = &ErrResponse{HTTPStatusCode: http.StatusNotFound, StatusText: http.StatusText(http.StatusNotFound)} + + ErrInternalServerError = &ErrResponse{HTTPStatusCode: http.StatusInternalServerError, StatusText: http.StatusText(http.StatusInternalServerError)} +) diff --git a/auth/handler.go b/auth/handler.go new file mode 100644 index 0000000..b57fd4a --- /dev/null +++ b/auth/handler.go @@ -0,0 +1,215 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + "path" + "strings" + "time" + + "github.com/go-chi/render" + validation "github.com/go-ozzo/ozzo-validation" + "github.com/go-ozzo/ozzo-validation/is" + "github.com/mssola/user_agent" + uuid "github.com/satori/go.uuid" + + "github.com/dhax/go-base/email" + "github.com/dhax/go-base/models" +) + +// The list of error types presented to the end user as error message. +var ( + ErrInvalidLogin = errors.New("invalid email address") + ErrUnknownLogin = errors.New("email not registered") + ErrLoginDisabled = errors.New("login for account disabled") + ErrLoginToken = errors.New("invalid or expired login token") +) + +type loginRequest struct { + Email string +} + +func (body *loginRequest) Bind(r *http.Request) error { + body.Email = strings.TrimSpace(body.Email) + body.Email = strings.ToLower(body.Email) + + if err := validation.ValidateStruct(body, + validation.Field(&body.Email, validation.Required, is.Email), + ); err != nil { + return err + } + return nil +} + +func (rs *Resource) login(w http.ResponseWriter, r *http.Request) { + body := &loginRequest{} + if err := render.Bind(r, body); err != nil { + log(r).WithField("email", body.Email).Warn(err) + render.Render(w, r, ErrUnauthorized(ErrInvalidLogin)) + return + } + + acc, err := rs.store.GetByEmail(body.Email) + if err != nil { + log(r).WithField("email", body.Email).Warn(err) + render.Render(w, r, ErrUnauthorized(ErrUnknownLogin)) + return + } + + if !acc.CanLogin() { + render.Render(w, r, ErrUnauthorized(ErrLoginDisabled)) + return + } + + lt := rs.Login.CreateToken(acc.ID) + + go func() { + err := rs.mailer.LoginToken(acc.Name, acc.Email, email.LoginTokenContent{ + Email: acc.Email, + Name: acc.Name, + URL: path.Join(rs.Login.loginURL, lt.Token), + Token: lt.Token, + Expiry: lt.Expiry, + }) + if err != nil { + log(r).WithField("module", "email").Error(err.Error()) + } + }() + + render.Respond(w, r, http.NoBody) +} + +type tokenRequest struct { + Token string `json:"token"` +} + +type tokenResponse struct { + Access string `json:"access_token"` + Refresh string `json:"refresh_token"` +} + +func (body *tokenRequest) Bind(r *http.Request) error { + body.Token = strings.TrimSpace(body.Token) + + if err := validation.ValidateStruct(body, + validation.Field(&body.Token, validation.Required, is.Alphanumeric), + ); err != nil { + return err + } + return nil +} + +func (rs *Resource) token(w http.ResponseWriter, r *http.Request) { + body := &tokenRequest{} + if err := render.Bind(r, body); err != nil { + log(r).Warn(err) + render.Render(w, r, ErrUnauthorized(ErrLoginToken)) + return + } + + id, err := rs.Login.GetAccountID(body.Token) + if err != nil { + render.Render(w, r, ErrUnauthorized(ErrLoginToken)) + return + } + + acc, err := rs.store.GetByID(id) + if err != nil { + // account deleted before login token expired + render.Render(w, r, ErrUnauthorized(ErrUnknownLogin)) + return + } + + if !acc.CanLogin() { + render.Render(w, r, ErrUnauthorized(ErrLoginDisabled)) + return + } + + ua := user_agent.New(r.UserAgent()) + browser, _ := ua.Browser() + token := &models.Token{ + Token: uuid.NewV4().String(), + Expiry: time.Now().Add(time.Minute * rs.Token.jwtRefreshExpiry), + UpdatedAt: time.Now(), + AccountID: acc.ID, + Mobile: ua.Mobile(), + Identifier: fmt.Sprintf("%s on %s", browser, ua.OS()), + } + + if err := rs.store.SaveRefreshToken(token); err != nil { + log(r).Error(err) + render.Respond(w, r, ErrInternalServerError) + return + } + + access, refresh := rs.Token.GenTokenPair(acc, token) + + acc.LastLogin = time.Now() + if err := rs.store.UpdateAccount(acc); err != nil { + log(r).Error(err) + render.Respond(w, r, ErrInternalServerError) + return + } + + render.Respond(w, r, &tokenResponse{ + Access: access, + Refresh: refresh, + }) +} + +func (rs *Resource) refresh(w http.ResponseWriter, r *http.Request) { + rt := RefreshTokenFromCtx(r.Context()) + + acc, token, err := rs.store.GetByRefreshToken(rt) + if err != nil { + render.Render(w, r, ErrUnauthorized(errTokenExpired)) + return + } + + if time.Now().After(token.Expiry) { + rs.store.DeleteRefreshToken(token) + render.Render(w, r, ErrUnauthorized(errTokenExpired)) + return + } + + if !acc.CanLogin() { + render.Render(w, r, ErrUnauthorized(ErrLoginDisabled)) + return + } + + token.Token = uuid.NewV4().String() + token.Expiry = time.Now().Add(time.Minute * rs.Token.jwtRefreshExpiry) + token.UpdatedAt = time.Now() + + access, refresh := rs.Token.GenTokenPair(acc, token) + if err := rs.store.SaveRefreshToken(token); err != nil { + log(r).Error(err) + render.Respond(w, r, ErrInternalServerError) + return + } + + acc.LastLogin = time.Now() + if err := rs.store.UpdateAccount(acc); err != nil { + log(r).Error(err) + render.Respond(w, r, ErrInternalServerError) + return + } + + render.Respond(w, r, &tokenResponse{ + Access: access, + Refresh: refresh, + }) +} + +func (rs *Resource) logout(w http.ResponseWriter, r *http.Request) { + rt := RefreshTokenFromCtx(r.Context()) + _, token, err := rs.store.GetByRefreshToken(rt) + if err != nil { + render.Render(w, r, ErrUnauthorized(errTokenExpired)) + return + } + rs.store.DeleteRefreshToken(token) + + render.Respond(w, r, http.NoBody) +} diff --git a/auth/handler_test.go b/auth/handler_test.go new file mode 100644 index 0000000..51f78d5 --- /dev/null +++ b/auth/handler_test.go @@ -0,0 +1,346 @@ +package auth + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/dhax/go-base/email" + "github.com/dhax/go-base/logging" + "github.com/dhax/go-base/models" + "github.com/dhax/go-base/testing/mock" + "github.com/go-chi/chi" + "github.com/go-chi/jwtauth" + "github.com/spf13/viper" +) + +var ( + auth *Resource + authstore mock.AuthStore + mailer mock.EmailService + ts *httptest.Server +) + +func TestMain(m *testing.M) { + viper.SetDefault("auth_login_token_length", 8) + viper.SetDefault("auth_login_token_expiry", 11) + viper.SetDefault("auth_jwt_secret", "random") + viper.SetDefault("log_level", "error") + + var err error + auth, err = NewResource(&authstore, &mailer) + if err != nil { + os.Exit(1) + } + + r := chi.NewRouter() + r.Use(logging.NewStructuredLogger(logging.NewLogger())) + r.Mount("/", auth.Router()) + + ts = httptest.NewServer(r) + + code := m.Run() + ts.Close() + os.Exit(code) +} + +func TestAuthResource_login(t *testing.T) { + authstore.GetByEmailFn = func(email string) (*models.Account, error) { + var err error + a := models.Account{ + ID: 1, + Email: email, + Name: "test", + } + + switch email { + case "not@exists.io": + err = errors.New("sql no row") + case "disabled@account.io": + a.Active = false + case "valid@account.io": + a.Active = true + } + return &a, err + } + + mailer.LoginTokenFn = func(n, e string, c email.LoginTokenContent) error { + return nil + } + + tests := []struct { + name string + email string + status int + err error + }{ + {"missing", "", http.StatusUnauthorized, ErrInvalidLogin}, + {"inexistent", "not@exists.io", http.StatusUnauthorized, ErrUnknownLogin}, + {"disabled", "disabled@account.io", http.StatusUnauthorized, ErrLoginDisabled}, + {"valid", "valid@account.io", http.StatusOK, nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req, err := encode(&loginRequest{Email: tc.email}) + if err != nil { + t.Fatal("failed to encode request body") + } + res, body := testRequest(t, ts, "POST", "/login", req, "") + + if res.StatusCode != tc.status { + t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status) + } + if tc.err != nil && !strings.Contains(body, tc.err.Error()) { + t.Errorf(" got: %s, expected to contain: %s", body, tc.err.Error()) + } + if tc.err == ErrInvalidLogin && authstore.GetByEmailInvoked { + t.Error("GetByLoginToken invoked for invalid email") + } + if tc.err == nil && !mailer.LoginTokenInvoked { + t.Error("emailService.LoginToken not invoked") + } + authstore.GetByEmailInvoked = false + mailer.LoginTokenInvoked = false + }) + } +} + +func TestAuthResource_token(t *testing.T) { + authstore.GetByIDFn = func(id int) (*models.Account, error) { + var err error + a := models.Account{ + ID: id, + Active: true, + Name: "test", + } + switch id { + case 2: + a.Active = false + case 3: + // unmodified + default: + err = errors.New("sql no rows") + } + return &a, err + } + authstore.UpdateAccountFn = func(a *models.Account) error { + a.LastLogin = time.Now() + return nil + } + authstore.SaveRefreshTokenFn = func(a *models.Token) error { + return nil + } + + tests := []struct { + name string + token string + id int + status int + err error + }{ + {"invalid", "#§$%", 0, http.StatusUnauthorized, ErrLoginToken}, + {"expired", "12345678", 0, http.StatusUnauthorized, ErrLoginToken}, + {"deleted_account", "", 1, http.StatusUnauthorized, ErrUnknownLogin}, + {"disabled", "", 2, http.StatusUnauthorized, ErrLoginDisabled}, + {"valid", "", 3, http.StatusOK, nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + token := auth.Login.CreateToken(tc.id) + if tc.token != "" { + token.Token = tc.token + } + + req, err := encode(tokenRequest{Token: token.Token}) + if err != nil { + t.Fatal("failed to encode request body") + } + res, body := testRequest(t, ts, "POST", "/token", req, "") + + if res.StatusCode != tc.status { + t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status) + } + if tc.err != nil && !strings.Contains(body, tc.err.Error()) { + t.Errorf("got: %s, expected to contain: %s", body, tc.err.Error()) + } + if tc.err == ErrLoginToken && authstore.SaveRefreshTokenInvoked { + t.Errorf("SaveRefreshToken invoked despite error %s", tc.err.Error()) + } + if tc.err == nil && !authstore.SaveRefreshTokenInvoked { + t.Error("SaveRefreshToken not invoked") + } + authstore.SaveRefreshTokenInvoked = false + }) + } +} + +func TestAuthResource_refresh(t *testing.T) { + authstore.GetByRefreshTokenFn = func(token string) (*models.Account, *models.Token, error) { + var err error + a := models.Account{ + Active: true, + Name: "Test", + } + var t models.Token + t.Expiry = time.Now().Add(1 * time.Minute) + + switch token { + case "notfound": + err = errors.New("sql no rows") + case "expired": + t.Expiry = time.Now().Add(-1 * time.Minute) + case "disabled": + a.Active = false + case "valid": + // unmodified + } + return &a, &t, err + } + authstore.UpdateAccountFn = func(a *models.Account) error { + a.LastLogin = time.Now() + return nil + } + authstore.SaveRefreshTokenFn = func(a *models.Token) error { + return nil + } + authstore.DeleteRefreshTokenFn = func(t *models.Token) error { + return nil + } + + tests := []struct { + name string + token string + exp time.Duration + status int + err error + }{ + {"notfound", "notfound", 1, http.StatusUnauthorized, errTokenExpired}, + {"expired", "expired", -1, http.StatusUnauthorized, errTokenUnauthorized}, + {"disabled", "disabled", 1, http.StatusUnauthorized, ErrLoginDisabled}, + {"valid", "valid", 1, http.StatusOK, nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + jwt := genJWT(jwtauth.Claims{"token": tc.token, "exp": time.Minute * tc.exp}) + res, body := testRequest(t, ts, "POST", "/refresh", nil, jwt) + if res.StatusCode != tc.status { + t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status) + } + if tc.err != nil && !strings.Contains(body, tc.err.Error()) { + t.Errorf("got: %s, expected error to contain: %s", body, tc.err.Error()) + } + if tc.status == http.StatusUnauthorized && authstore.SaveRefreshTokenInvoked { + t.Errorf("SaveRefreshToken invoked for status %d", tc.status) + } + if tc.status == http.StatusOK && !authstore.GetByRefreshTokenInvoked { + t.Errorf("GetRefreshToken not invoked") + } + if tc.status == http.StatusOK && !authstore.SaveRefreshTokenInvoked { + t.Errorf("SaveRefreshToken not invoked") + } + if tc.status == http.StatusOK && authstore.DeleteRefreshTokenInvoked { + t.Errorf("DeleteRefreshToken should not be invoked") + } + authstore.GetByRefreshTokenInvoked = false + authstore.SaveRefreshTokenInvoked = false + authstore.DeleteRefreshTokenInvoked = false + }) + } +} + +func TestAuthResource_logout(t *testing.T) { + authstore.GetByRefreshTokenFn = func(token string) (*models.Account, *models.Token, error) { + var err error + var a models.Account + t := models.Token{ + Expiry: time.Now().Add(1 * time.Minute), + } + + switch token { + case "notfound": + err = errors.New("sql no rows") + } + return &a, &t, err + } + authstore.DeleteRefreshTokenFn = func(a *models.Token) error { + return nil + } + + tests := []struct { + name string + token string + exp time.Duration + status int + err error + }{ + {"notfound", "notfound", 1, http.StatusUnauthorized, errTokenExpired}, + {"expired", "valid", -1, http.StatusUnauthorized, errTokenUnauthorized}, + {"valid", "valid", 1, http.StatusOK, nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + jwt := genJWT(jwtauth.Claims{"token": tc.token, "exp": time.Minute * tc.exp}) + res, body := testRequest(t, ts, "POST", "/logout", nil, jwt) + if res.StatusCode != tc.status { + t.Errorf("got http status %d, want: %d", res.StatusCode, tc.status) + } + if tc.err != nil && !strings.Contains(body, tc.err.Error()) { + t.Errorf("got: %x, expected error to contain %s", body, tc.err.Error()) + } + if tc.status == http.StatusUnauthorized && authstore.DeleteRefreshTokenInvoked { + t.Errorf("DeleteRefreshToken invoked for status %d", tc.status) + } + authstore.DeleteRefreshTokenInvoked = false + }) + } +} + +func testRequest(t *testing.T, ts *httptest.Server, method, path string, body io.Reader, token string) (*http.Response, string) { + req, err := http.NewRequest(method, ts.URL+path, body) + if err != nil { + t.Fatal(err) + return nil, "" + } + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "BEARER "+token) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + return nil, "" + } + defer resp.Body.Close() + + respBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + return nil, "" + } + + return resp, string(respBody) +} + +func genJWT(c jwtauth.Claims) string { + _, tokenString, _ := auth.Token.JwtAuth.Encode(c) + return tokenString +} + +func encode(v interface{}) (*bytes.Buffer, error) { + data := new(bytes.Buffer) + err := json.NewEncoder(data).Encode(v) + return data, err +} diff --git a/auth/jwt.go b/auth/jwt.go new file mode 100644 index 0000000..e737ad2 --- /dev/null +++ b/auth/jwt.go @@ -0,0 +1,118 @@ +package auth + +import ( + "net/http" + "time" + + "github.com/dhax/go-base/models" + "github.com/go-chi/jwtauth" + "github.com/spf13/viper" +) + +// AppClaims represent the claims extracted from JWT token. +type AppClaims struct { + ID int + Sub string + Roles []string +} + +// TokenAuth implements JWT authentication flow. +type TokenAuth struct { + JwtAuth *jwtauth.JwtAuth + jwtExpiry time.Duration + jwtRefreshExpiry time.Duration +} + +// NewTokenAuth configures and returns a JWT authentication instance. +func NewTokenAuth() (*TokenAuth, error) { + secret := viper.GetString("auth_jwt_secret") + if secret == "random" { + secret = randStringBytes(32) + } + + a := &TokenAuth{ + JwtAuth: jwtauth.New("HS256", []byte(secret), nil), + jwtExpiry: viper.GetDuration("auth_jwt_expiry"), + jwtRefreshExpiry: viper.GetDuration("auth_jwt_refresh_expiry"), + } + + return a, nil +} + +// Verifier http middleware will verify a jwt string from a http request. +func (a *TokenAuth) Verifier() func(http.Handler) http.Handler { + return jwtauth.Verifier(a.JwtAuth) +} + +// GenTokenPair returns both an access token and a refresh token for provided account. +func (a *TokenAuth) GenTokenPair(u *models.Account, tok *models.Token) (string, string) { + access := a.CreateJWT(u) + refresh := a.CreateRefreshJWT(tok) + return access, refresh +} + +// CreateJWT returns an access token for provided account. +func (a *TokenAuth) CreateJWT(acc *models.Account) string { + claims := jwtauth.Claims{ + "id": acc.ID, + "sub": acc.Name, + "roles": acc.Roles, + } + claims.SetIssuedNow() + claims.SetExpiryIn(a.jwtExpiry * time.Minute) + + _, tokenString, _ := a.JwtAuth.Encode(claims) + return tokenString +} + +// CreateRefreshJWT returns a refresh token for provided account. +func (a *TokenAuth) CreateRefreshJWT(tok *models.Token) string { + claims := jwtauth.Claims{ + "id": tok.ID, + "token": tok.Token, + } + claims.SetIssuedNow() + claims.SetExpiryIn(time.Minute * a.jwtRefreshExpiry) + + _, tokenString, _ := a.JwtAuth.Encode(claims) + return tokenString +} + +func parseClaims(c jwtauth.Claims) (AppClaims, bool) { + var claims AppClaims + allOK := true + id, ok := c.Get("id") + if !ok { + allOK = false + } + claims.ID = int(id.(float64)) + + sub, ok := c.Get("sub") + if !ok { + allOK = false + } + claims.Sub = sub.(string) + + rl, ok := c.Get("roles") + if !ok { + allOK = false + } + + var roles []string + if rl != nil { + for _, v := range rl.([]interface{}) { + roles = append(roles, v.(string)) + } + } + claims.Roles = roles + + return claims, allOK +} + +func parseRefreshClaims(c jwtauth.Claims) (string, bool) { + token, ok := c.Get("token") + if !ok { + return "", false + } + return token.(string), ok +} diff --git a/auth/logintoken.go b/auth/logintoken.go new file mode 100644 index 0000000..b3f3aa2 --- /dev/null +++ b/auth/logintoken.go @@ -0,0 +1,89 @@ +package auth + +import ( + "errors" + "sync" + "time" + + "github.com/spf13/viper" +) + +var ( + errTokenNotFound = errors.New("login token not found") +) + +// LoginToken is an in-memory saved token referencing an account ID and an expiry date. +type LoginToken struct { + Token string + AccountID int + Expiry time.Time +} + +// LoginTokenAuth implements passwordless login authentication flow using temporary in-memory stored tokens. +type LoginTokenAuth struct { + token map[string]LoginToken + mux sync.RWMutex + loginURL string + loginTokenLength int + loginTokenExpiry time.Duration +} + +// NewLoginTokenAuth configures and returns a LoginToken authentication instance. +func NewLoginTokenAuth() (*LoginTokenAuth, error) { + a := &LoginTokenAuth{ + token: make(map[string]LoginToken), + loginURL: viper.GetString("auth_login_url"), + loginTokenLength: viper.GetInt("auth_login_token_length"), + loginTokenExpiry: viper.GetDuration("auth_login_token_expiry"), + } + return a, nil +} + +// CreateToken creates an in-memory login token referencing account ID. It returns a token containing a random tokenstring and expiry date. +func (a *LoginTokenAuth) CreateToken(id int) LoginToken { + lt := LoginToken{ + Token: randStringBytes(a.loginTokenLength), + AccountID: id, + Expiry: time.Now().Add(time.Minute * a.loginTokenExpiry), + } + a.add(lt) + a.purgeExpired() + return lt +} + +// GetAccountID looks up the token by tokenstring and returns the account ID or error if token not found or expired. +func (a *LoginTokenAuth) GetAccountID(token string) (int, error) { + lt, exists := a.get(token) + if !exists || time.Now().After(lt.Expiry) { + return 0, errTokenNotFound + } + a.delete(lt.Token) + return lt.AccountID, nil +} + +func (a *LoginTokenAuth) get(token string) (LoginToken, bool) { + a.mux.RLock() + lt, ok := a.token[token] + a.mux.RUnlock() + return lt, ok +} + +func (a *LoginTokenAuth) add(lt LoginToken) { + a.mux.Lock() + a.token[lt.Token] = lt + a.mux.Unlock() +} + +func (a *LoginTokenAuth) delete(token string) { + a.mux.Lock() + delete(a.token, token) + a.mux.Unlock() +} + +func (a *LoginTokenAuth) purgeExpired() { + for t, v := range a.token { + if time.Now().After(v.Expiry) { + a.delete(t) + } + } +} diff --git a/cmd/migrate.go b/cmd/migrate.go new file mode 100644 index 0000000..4b35caa --- /dev/null +++ b/cmd/migrate.go @@ -0,0 +1,60 @@ +// Copyright © 2017 NAME HERE +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/spf13/cobra" + + "github.com/dhax/go-base/database/migrate" +) + +var reset bool + +// migrateCmd represents the migrate command +var migrateCmd = &cobra.Command{ + Use: "migrate", + Short: "use go-pg migration tool", + Long: `migrate uses go-pg migration tool under the hood supporting the same commands and an additional reset command`, + Run: func(cmd *cobra.Command, args []string) { + argsMig := args[:0] + for _, arg := range args { + switch arg { + case "migrate", "--db_debug", "--reset": + default: + argsMig = append(argsMig, arg) + } + } + + if reset { + migrate.Reset() + } + migrate.Migrate(argsMig) + }, +} + +func init() { + RootCmd.AddCommand(migrateCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // migrateCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // migrateCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") + migrateCmd.Flags().BoolVar(&reset, "reset", false, "migrate down to version 0 then up to latest. WARNING: all data will be lost!") +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..50ed141 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,94 @@ +// Copyright © 2017 NAME HERE +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + "os" + + homedir "github.com/mitchellh/go-homedir" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var cfgFile string + +// RootCmd represents the base command when called without any subcommands +var RootCmd = &cobra.Command{ + Use: "go-base", + Short: "A brief description of your application", + Long: `A longer description that spans multiple lines and likely contains +examples and usage of using your application. For example: + +Cobra is a CLI library for Go that empowers applications. +This application is a tool to generate the needed files +to quickly create a Cobra application.`, + // Uncomment the following line if your bare application + // has an action associated with it: + // Run: func(cmd *cobra.Command, args []string) { }, +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := RootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func init() { + cobra.OnInitialize(initConfig) + + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. + RootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.go-base.yaml)") + + viper.SetDefault("database_url", "postgres://postgres:postgres@localhost:5432/gobase?sslmode=disable") + + RootCmd.PersistentFlags().Bool("db_debug", false, "log sql to console") + viper.BindPFlag("db_debug", RootCmd.PersistentFlags().Lookup("db_debug")) + + // Cobra also supports local flags, which will only run + // when this action is called directly. + // RootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} + +// initConfig reads in config file and ENV variables if set. +func initConfig() { + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Find home directory. + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Search config in home directory with name ".go-base" (without extension). + viper.AddConfigPath(home) + viper.SetConfigName(".go-base") + } + + viper.AutomaticEnv() // read in environment variables that match + + // If a config file is found, read it in. + if err := viper.ReadInConfig(); err == nil { + fmt.Println("Using config file:", viper.ConfigFileUsed()) + } +} diff --git a/cmd/serve.go b/cmd/serve.go new file mode 100644 index 0000000..08e4582 --- /dev/null +++ b/cmd/serve.go @@ -0,0 +1,58 @@ +// Copyright © 2017 NAME HERE +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "github.com/dhax/go-base/api" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// serveCmd represents the serve command +var serveCmd = &cobra.Command{ + Use: "serve", + Short: "start http server with configured api", + Long: `Starts a http server and serves the configured api`, + Run: func(cmd *cobra.Command, args []string) { + server, err := api.NewServer() + if err != nil { + panic(err) + } + server.Start() + }, +} + +func init() { + RootCmd.AddCommand(serveCmd) + + // Here you will define your flags and configuration settings. + viper.SetDefault("port", "3000") + viper.SetDefault("log_level", "debug") + + viper.SetDefault("auth_login_url", "http://localhost:3000/login") + viper.SetDefault("auth_login_token_length", 8) + viper.SetDefault("auth_login_token_expiry", 11) // expiry in minutes + viper.SetDefault("auth_jwt_secret", "random") + viper.SetDefault("auth_jwt_expiry", 15) + viper.SetDefault("auth_jwt_refresh_expiry", 60) + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // serveCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // serveCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} diff --git a/database/accountStore.go b/database/accountStore.go new file mode 100644 index 0000000..7f12af0 --- /dev/null +++ b/database/accountStore.go @@ -0,0 +1,66 @@ +package database + +import ( + "github.com/dhax/go-base/models" + "github.com/go-pg/pg" +) + +type AccountStore struct { + db *pg.DB +} + +func NewAccountStore(db *pg.DB) *AccountStore { + return &AccountStore{ + db: db, + } +} + +func (s *AccountStore) Get(id int) (*models.Account, error) { + a := models.Account{ID: id} + err := s.db.Model(&a). + Where("account.id = ?id"). + Column("account.*", "Profile", "Token"). + First() + return &a, err +} + +func (s *AccountStore) Update(a *models.Account) error { + _, err := s.db.Model(a). + Column("email", "name"). + Update() + return err +} + +func (s *AccountStore) Delete(a *models.Account) error { + err := s.db.RunInTransaction(func(tx *pg.Tx) error { + if _, err := tx.Model(&models.Token{}). + Where("account_id = ?", a.ID). + Delete(); err != nil { + return err + } + if _, err := tx.Model(&models.Profile{}). + Where("account_id = ?", a.ID). + Delete(); err != nil { + return err + } + return tx.Delete(a) + }) + return err +} + +func (s *AccountStore) UpdateToken(t *models.Token) error { + _, err := s.db.Model(t). + Column("identifier"). + Update() + return err +} + +func (s *AccountStore) DeleteToken(t *models.Token) error { + err := s.db.Delete(t) + return err +} + +func (s *AccountStore) UpdateProfile(p *models.Profile) error { + err := s.db.Update(p) + return err +} diff --git a/database/admAccountStore.go b/database/admAccountStore.go new file mode 100644 index 0000000..d11450d --- /dev/null +++ b/database/admAccountStore.go @@ -0,0 +1,84 @@ +package database + +import ( + "errors" + + "github.com/dhax/go-base/models" + "github.com/go-pg/pg" +) + +var ( + ErrUniqueEmailConstraint = errors.New("email already registered") +) + +type AdmAccountStore struct { + db *pg.DB +} + +func NewAdmAccountStore(db *pg.DB) *AdmAccountStore { + return &AdmAccountStore{ + db: db, + } +} + +func (s *AdmAccountStore) List(f models.AccountFilter) (*[]models.Account, int, error) { + var a []models.Account + count, err := s.db.Model(&a). + Apply(f.Filter). + SelectAndCount() + if err != nil { + return nil, 0, err + } + return &a, count, nil +} + +func (s *AdmAccountStore) Create(a *models.Account) error { + count, _ := s.db.Model(a). + Where("email = ?email"). + Count() + + if count != 0 { + return ErrUniqueEmailConstraint + } + + err := s.db.RunInTransaction(func(tx *pg.Tx) error { + err := tx.Insert(a) + if err != nil { + return err + } + p := &models.Profile{ + AccountID: a.ID, + } + return tx.Insert(p) + }) + + return err +} + +func (s *AdmAccountStore) Get(id int) (*models.Account, error) { + a := models.Account{ID: id} + err := s.db.Select(&a) + return &a, err +} + +func (s *AdmAccountStore) Update(a *models.Account) error { + err := s.db.Update(a) + return err +} + +func (s *AdmAccountStore) Delete(a *models.Account) error { + err := s.db.RunInTransaction(func(tx *pg.Tx) error { + if _, err := tx.Model(&models.Token{}). + Where("account_id = ?", a.ID). + Delete(); err != nil { + return err + } + if _, err := tx.Model(&models.Profile{}). + Where("account_id = ?", a.ID). + Delete(); err != nil { + return err + } + return tx.Delete(a) + }) + return err +} diff --git a/database/authStore.go b/database/authStore.go new file mode 100644 index 0000000..94a8ab7 --- /dev/null +++ b/database/authStore.go @@ -0,0 +1,84 @@ +package database + +import ( + "time" + + "github.com/dhax/go-base/models" + "github.com/go-pg/pg" +) + +type AuthStore struct { + db *pg.DB +} + +func NewAuthStore(db *pg.DB) *AuthStore { + return &AuthStore{ + db: db, + } +} + +func (s *AuthStore) GetByID(id int) (*models.Account, error) { + a := models.Account{ID: id} + err := s.db.Model(&a). + Column("account.*"). + Where("id = ?id"). + First() + return &a, err +} + +func (s *AuthStore) GetByEmail(e string) (*models.Account, error) { + a := models.Account{Email: e} + err := s.db.Model(&a). + Column("id", "active", "email", "name"). + Where("email = ?email"). + First() + return &a, err +} + +func (s *AuthStore) GetByRefreshToken(t string) (*models.Account, *models.Token, error) { + token := models.Token{Token: t} + err := s.db.Model(&token). + Where("token = ?token"). + First() + if err != nil { + return nil, nil, err + } + + a := models.Account{ID: token.AccountID} + err = s.db.Model(&a). + Column("account.*"). + Where("id = ?id"). + First() + + return &a, &token, err +} + +func (s *AuthStore) UpdateAccount(a *models.Account) error { + _, err := s.db.Model(a). + Column("last_login"). + Update() + return err +} + +func (s *AuthStore) SaveRefreshToken(t *models.Token) error { + var err error + if t.ID == 0 { + err = s.db.Insert(t) + } else { + err = s.db.Update(t) + } + return err +} + +func (s *AuthStore) DeleteRefreshToken(t *models.Token) error { + err := s.db.Delete(t) + return err +} + +func (s *AuthStore) PurgeExpiredToken() error { + _, err := s.db.Model(&models.Token{}). + Where("expiry < ?", time.Now()). + Delete() + + return err +} diff --git a/database/migrate/1_initial.go b/database/migrate/1_initial.go new file mode 100644 index 0000000..d7b5a07 --- /dev/null +++ b/database/migrate/1_initial.go @@ -0,0 +1,65 @@ +package migrate + +import ( + "fmt" + + "github.com/go-pg/migrations" +) + +const AccountTable = ` +CREATE TABLE accounts ( +id serial NOT NULL, +created_at timestamp with time zone NOT NULL DEFAULT current_timestamp, +updated_at timestamp with time zone DEFAULT current_timestamp, +last_login timestamp with time zone NOT NULL DEFAULT current_timestamp, +email text NOT NULL UNIQUE, +name text NOT NULL, +active boolean NOT NULL DEFAULT TRUE, +roles text[] NOT NULL DEFAULT '{"user"}', +PRIMARY KEY (id) +)` + +const TokenTable = ` +CREATE TABLE tokens ( +id serial NOT NULL, +created_at timestamp with time zone NOT NULL DEFAULT current_timestamp, +updated_at timestamp with time zone NOT NULL DEFAULT current_timestamp, +account_id int NOT NULL REFERENCES accounts(id), +token text NOT NULL UNIQUE, +expiry timestamp with time zone NOT NULL, +mobile boolean NOT NULL DEFAULT FALSE, +identifier text, +PRIMARY KEY (id) +)` + +func init() { + up := []string{ + AccountTable, + TokenTable, + } + + down := []string{ + `DROP TABLE tokens`, + `DROP TABLE accounts`, + } + + migrations.Register(func(db migrations.DB) error { + fmt.Println("creating initial tables") + for _, q := range up { + _, err := db.Exec(q) + if err != nil { + return err + } + } + return nil + }, func(db migrations.DB) error { + fmt.Println("dropping initial tables") + for _, q := range down { + _, err := db.Exec(q) + if err != nil { + return err + } + } + return nil + }) +} diff --git a/database/migrate/2_bootstrap_users.go b/database/migrate/2_bootstrap_users.go new file mode 100644 index 0000000..839348c --- /dev/null +++ b/database/migrate/2_bootstrap_users.go @@ -0,0 +1,48 @@ +package migrate + +import ( + "fmt" + + "github.com/go-pg/migrations" +) + +const bootstrapAdminAccount = ` +INSERT INTO accounts (id, email, name, active, roles) +VALUES (DEFAULT, 'admin@boot.io', 'Admin Boot', true, '{admin}') +` + +const bootstrapUserAccount = ` +INSERT INTO accounts (id, email, name, active) +VALUES (DEFAULT, 'user@boot.io', 'User Boot', true) +` + +func init() { + up := []string{ + bootstrapAdminAccount, + bootstrapUserAccount, + } + + down := []string{ + `TRUNCATE accounts CASCADE`, + } + + migrations.Register(func(db migrations.DB) error { + fmt.Println("add bootstrap accounts") + for _, q := range up { + _, err := db.Exec(q) + if err != nil { + return err + } + } + return nil + }, func(db migrations.DB) error { + fmt.Println("truncate accounts cascading") + for _, q := range down { + _, err := db.Exec(q) + if err != nil { + return err + } + } + return nil + }) +} diff --git a/database/migrate/3_add_profile_table.go b/database/migrate/3_add_profile_table.go new file mode 100644 index 0000000..5876d2a --- /dev/null +++ b/database/migrate/3_add_profile_table.go @@ -0,0 +1,53 @@ +package migrate + +import ( + "fmt" + + "github.com/go-pg/migrations" +) + +const ProfileTable = ` +CREATE TABLE profiles ( +id serial NOT NULL, +created_at timestamp with time zone NOT NULL DEFAULT current_timestamp, +updated_at timestamp with time zone NOT NULL DEFAULT current_timestamp, +account_id int NOT NULL REFERENCES accounts(id), +theme text NOT NULL DEFAULT 'default', +PRIMARY KEY (id) +)` + +const bootstrapAccountProfiles = ` +INSERT INTO profiles(account_id) VALUES(1); +INSERT INTO profiles(account_id) VALUES(2); +` + +func init() { + up := []string{ + ProfileTable, + bootstrapAccountProfiles, + } + + down := []string{ + `DROP TABLE profiles`, + } + + migrations.Register(func(db migrations.DB) error { + fmt.Println("create profile table") + for _, q := range up { + _, err := db.Exec(q) + if err != nil { + return err + } + } + return nil + }, func(db migrations.DB) error { + fmt.Println("drop profile table") + for _, q := range down { + _, err := db.Exec(q) + if err != nil { + return err + } + } + return nil + }) +} diff --git a/database/migrate/main.go b/database/migrate/main.go new file mode 100644 index 0000000..12054f0 --- /dev/null +++ b/database/migrate/main.go @@ -0,0 +1,70 @@ +package migrate + +import ( + "fmt" + "os" + + "github.com/dhax/go-base/database" + "github.com/go-pg/migrations" + "github.com/go-pg/pg" +) + +func Migrate(args []string) { + db, err := database.DBConn() + if err != nil { + panic(err) + } + + err = db.RunInTransaction(func(tx *pg.Tx) error { + oldVersion, newVersion, err := migrations.Run(tx, args...) + if err != nil { + return err + } + if newVersion != oldVersion { + fmt.Printf("migrated from version %d to %d\n", oldVersion, newVersion) + } else { + fmt.Printf("version is %d\n", oldVersion) + } + return nil + }) + if err != nil { + exitf(err.Error()) + } + +} + +func Reset() { + db, err := database.DBConn() + if err != nil { + exitf(err.Error()) + } + + version, err := migrations.Version(db) + if err != nil { + exitf(err.Error()) + } + + err = db.RunInTransaction(func(tx *pg.Tx) error { + for version != 0 { + oldVersion, newVersion, err := migrations.Run(tx, "down") + if err != nil { + return err + } + fmt.Printf("migrated from version %d to %d\n", oldVersion, newVersion) + version = newVersion + } + return nil + }) + if err != nil { + exitf(err.Error()) + } +} + +func errorf(s string, args ...interface{}) { + fmt.Fprintf(os.Stderr, s+"\n", args...) +} + +func exitf(s string, args ...interface{}) { + errorf(s, args...) + os.Exit(1) +} diff --git a/database/postgres.go b/database/postgres.go new file mode 100644 index 0000000..30eae05 --- /dev/null +++ b/database/postgres.go @@ -0,0 +1,44 @@ +package database + +import ( + "fmt" + "time" + + "github.com/spf13/viper" + + "github.com/go-pg/pg" +) + +// DBConn returns a postgres connection pool +func DBConn() (*pg.DB, error) { + + opts, err := pg.ParseURL(viper.GetString("database_url")) + if err != nil { + return nil, err + } + + db := pg.Connect(opts) + if err := checkConn(db); err != nil { + return nil, err + } + + if viper.GetBool("db_debug") { + db.OnQueryProcessed(func(event *pg.QueryProcessedEvent) { + query, err := event.FormattedQuery() + if err != nil { + panic(err) + } + fmt.Printf("%s %s\n", time.Since(event.StartTime), query) + }) + } + + return db, nil +} + +func checkConn(db *pg.DB) error { + var n int + if _, err := db.QueryOne(pg.Scan(&n), "SELECT 1"); err != nil { + return err + } + return nil +} diff --git a/email/auth.go b/email/auth.go new file mode 100644 index 0000000..8bd950e --- /dev/null +++ b/email/auth.go @@ -0,0 +1,24 @@ +package email + +import "time" + +type LoginTokenContent struct { + Email string + Name string + URL string + Token string + Expiry time.Time +} + +func (s *EmailService) LoginToken(name, address string, content LoginTokenContent) error { + msg := &Message{ + from: NewEmail(s.fromName, s.from), + to: NewEmail(name, address), + subject: "Login Token", + template: "loginToken", + content: content, + } + + err := s.send(msg) + return err +} diff --git a/email/email.go b/email/email.go new file mode 100644 index 0000000..a02ba5e --- /dev/null +++ b/email/email.go @@ -0,0 +1,148 @@ +package email + +import ( + "bytes" + "fmt" + "html/template" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/jaytaylor/html2text" + "github.com/spf13/viper" + "github.com/vanng822/go-premailer/premailer" + "gopkg.in/gomail.v2" +) + +var ( + debug bool +) + +type EmailService struct { + client *gomail.Dialer + templates *template.Template + from, fromName string +} + +func NewEmailService() (*EmailService, error) { + templates, err := parseTemplates() + if err != nil { + return nil, err + } + + smtpHost := viper.GetString("email_smtp_host") + smtpPort := viper.GetInt("email_smtp_port") + smtpUser := viper.GetString("email_smtp_user") + smtpPass := viper.GetString("email_smtp_password") + + s := &EmailService{ + client: gomail.NewPlainDialer(smtpHost, smtpPort, smtpUser, smtpPass), + templates: templates, + from: viper.GetString("email_from_address"), + fromName: viper.GetString("email_from_name"), + } + + d, err := s.client.Dial() + if err != nil { + log.Println("SMTP error:", err) + log.Println("printing emails to stdout") + debug = true + } else { + d.Close() + } + return s, nil +} + +func (s *EmailService) send(msg *Message) error { + buf := new(bytes.Buffer) + if err := s.templates.ExecuteTemplate(buf, msg.template, msg.content); err != nil { + return err + } + prem := premailer.NewPremailerFromString(buf.String(), premailer.NewOptions()) + html, err := prem.Transform() + if err != nil { + return err + } + + text, err := html2text.FromString(html, html2text.Options{PrettyTables: true}) + if err != nil { + return err + } + + if debug { + log.Println("To:", msg.to.Address) + log.Println("Subject:", msg.subject) + log.Println(text) + return nil + } + + m := gomail.NewMessage() + m.SetAddressHeader("From", msg.from.Address, msg.from.Name) + m.SetAddressHeader("To", msg.to.Address, msg.to.Name) + m.SetHeader("Subject", msg.subject) + m.SetBody("text/plain", text) + m.AddAlternative("text/html", html) + + if err := s.client.DialAndSend(m); err != nil { + return err + } + return nil +} + +type Email struct { + Name string + Address string +} + +func NewEmail(name string, address string) *Email { + return &Email{ + Name: name, + Address: address, + } +} + +type Message struct { + from *Email + to *Email + subject string + template string + content interface{} +} + +func parseTemplates() (*template.Template, error) { + tmpl := template.New("").Funcs(fMap) + err := filepath.Walk("./templates", func(path string, info os.FileInfo, err error) error { + if strings.Contains(path, ".html") { + _, err = tmpl.ParseFiles(path) + return err + } + return err + }) + return tmpl, err +} + +var fMap = template.FuncMap{ + "formatAsDate": formatAsDate, + "formatAsDuration": formatAsDuration, +} + +func formatAsDate(t time.Time) string { + year, month, day := t.Date() + return fmt.Sprintf("%d.%d.%d", day, month, year) +} + +func formatAsDuration(t time.Time) string { + dur := t.Sub(time.Now()) + hours := int(dur.Hours()) + mins := int(dur.Minutes()) + + v := "" + if hours != 0 { + v += strconv.Itoa(hours) + " hours and " + } + v += strconv.Itoa(mins) + " minutes" + return v +} diff --git a/logging/logger.go b/logging/logger.go new file mode 100644 index 0000000..a319afd --- /dev/null +++ b/logging/logger.go @@ -0,0 +1,121 @@ +package logging + +import ( + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/middleware" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" +) + +var Logger *logrus.Logger + +// StructuredLogger is a structured logrus Logger. +type StructuredLogger struct { + Logger *logrus.Logger +} + +// NewLogger creates and configures a new logrus Logger. +func NewLogger() *logrus.Logger { + Logger = logrus.New() + if viper.GetBool("log_textlogging") { + Logger.Formatter = &logrus.TextFormatter{ + DisableTimestamp: true, + } + } else { + Logger.Formatter = &logrus.JSONFormatter{ + DisableTimestamp: true, + } + } + + level := viper.GetString("log_level") + if level == "" { + level = "error" + } + l, err := logrus.ParseLevel(level) + if err != nil { + panic(err) + } + Logger.Level = l + return Logger +} + +// NewStructuredLogger implements a custom structured logrus Logger. +func NewStructuredLogger(logger *logrus.Logger) func(next http.Handler) http.Handler { + return middleware.RequestLogger(&StructuredLogger{Logger}) +} + +func (l *StructuredLogger) NewLogEntry(r *http.Request) middleware.LogEntry { + entry := &StructuredLoggerEntry{Logger: logrus.NewEntry(l.Logger)} + logFields := logrus.Fields{} + + logFields["ts"] = time.Now().UTC().Format(time.RFC1123) + + if reqID := middleware.GetReqID(r.Context()); reqID != "" { + logFields["req_id"] = reqID + } + + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + logFields["http_scheme"] = scheme + logFields["http_proto"] = r.Proto + logFields["http_method"] = r.Method + + logFields["remote_addr"] = r.RemoteAddr + logFields["user_agent"] = r.UserAgent() + + logFields["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI) + logFields["uri"] = fmt.Sprintf("%s", r.RequestURI) + + entry.Logger = entry.Logger.WithFields(logFields) + + entry.Logger.Infoln("request started") + + return entry +} + +type StructuredLoggerEntry struct { + Logger logrus.FieldLogger +} + +func (l *StructuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) { + l.Logger = l.Logger.WithFields(logrus.Fields{ + "resp_status": status, + "resp_bytes_length": bytes, + "resp_elasped_ms": float64(elapsed.Nanoseconds()) / 1000000.0, + }) + + l.Logger.Infoln("request complete") +} + +func (l *StructuredLoggerEntry) Panic(v interface{}, stack []byte) { + l.Logger = l.Logger.WithFields(logrus.Fields{ + "stack": string(stack), + "panic": fmt.Sprintf("%+v", v), + }) +} + +// Helper methods used by the application to get the request-scoped +// logger entry and set additional fields between handlers. + +func GetLogEntry(r *http.Request) logrus.FieldLogger { + entry := middleware.GetLogEntry(r).(*StructuredLoggerEntry) + return entry.Logger +} + +func LogEntrySetField(r *http.Request, key string, value interface{}) { + if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok { + entry.Logger = entry.Logger.WithField(key, value) + } +} + +func LogEntrySetFields(r *http.Request, fields map[string]interface{}) { + if entry, ok := r.Context().Value(middleware.LogEntryCtxKey).(*StructuredLoggerEntry); ok { + entry.Logger = entry.Logger.WithFields(fields) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4d6d56b --- /dev/null +++ b/main.go @@ -0,0 +1,21 @@ +// Copyright © 2017 NAME HERE +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import "github.com/dhax/go-base/cmd" + +func main() { + cmd.Execute() +} diff --git a/models/account.go b/models/account.go new file mode 100644 index 0000000..a69751f --- /dev/null +++ b/models/account.go @@ -0,0 +1,86 @@ +package models + +import ( + "net/url" + "strings" + "time" + + validation "github.com/go-ozzo/ozzo-validation" + "github.com/go-ozzo/ozzo-validation/is" + "github.com/go-pg/pg/orm" +) + +type Account struct { + ID int `json:"id"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastLogin time.Time `json:"last_login,omitempty"` + + Email string `json:"email"` + Name string `json:"name"` + Active bool `sql:",notnull" json:"active"` + Roles []string `pg:",array" json:"roles,omitempty"` + + Profile *Profile `json:"profile,omitempty"` + Token []*Token `json:"token,omitempty"` +} + +func (a *Account) BeforeInsert(db orm.DB) error { + now := time.Now() + if a.CreatedAt.IsZero() { + a.CreatedAt = now + a.UpdatedAt = now + } + if err := a.Validate(); err != nil { + return err + } + return nil +} + +func (a *Account) BeforeUpdate(db orm.DB) error { + if err := a.Validate(); err != nil { + return err + } + a.UpdatedAt = time.Now() + return nil +} + +func (a *Account) BeforeDelete(db orm.DB) error { + return nil +} + +func (a *Account) Validate() error { + a.Email = strings.TrimSpace(a.Email) + a.Email = strings.ToLower(a.Email) + a.Name = strings.TrimSpace(a.Name) + + return validation.ValidateStruct(a, + validation.Field(&a.Email, validation.Required, is.Email, is.LowerCase), + validation.Field(&a.Name, validation.Required, is.ASCII), + ) +} + +func (a *Account) CanLogin() bool { + return a.Active +} + +type AccountFilter struct { + orm.Pager + Filters url.Values + Order []string +} + +func (f *AccountFilter) Filter(q *orm.Query) (*orm.Query, error) { + q = q.Apply(f.Pager.Paginate) + q = q.Apply(orm.URLFilters(f.Filters)) + q = q.Order(f.Order...) + return q, nil +} + +func NewAccountFilter(v url.Values) AccountFilter { + var f AccountFilter + f.SetURLValues(v) + f.Filters = v + f.Order = v["order"] + return f +} diff --git a/models/profile.go b/models/profile.go new file mode 100644 index 0000000..f74f585 --- /dev/null +++ b/models/profile.go @@ -0,0 +1,42 @@ +package models + +import ( + "time" + + "github.com/go-ozzo/ozzo-validation" + + "github.com/go-pg/pg/orm" +) + +type Profile struct { + ID int `json:"id,omitempty"` + AccountID int `json:"-"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + + Theme string `json:"theme,omitempty"` +} + +func (p *Profile) BeforeInsert(db orm.DB) error { + now := time.Now() + if p.CreatedAt.IsZero() { + p.CreatedAt = now + p.UpdatedAt = now + } + return nil +} + +func (p *Profile) BeforeUpdate(db orm.DB) error { + if err := p.Validate(); err != nil { + return err + } + p.UpdatedAt = time.Now() + return nil +} + +func (p *Profile) Validate() error { + + return validation.ValidateStruct(p, + validation.Field(&p.Theme, validation.Required, validation.In("default", "dark")), + ) +} diff --git a/models/token.go b/models/token.go new file mode 100644 index 0000000..48c0b9a --- /dev/null +++ b/models/token.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "github.com/go-pg/pg/orm" +) + +type Token struct { + ID int `json:"id,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + AccountID int `json:"-"` + + Token string `json:"-"` + Expiry time.Time `json:"-"` + Mobile bool `sql:",notnull" json:"mobile"` + Identifier string `json:"identifier,omitempty"` +} + +func (t *Token) BeforeInsert(db orm.DB) error { + now := time.Now() + if t.CreatedAt.IsZero() { + t.CreatedAt = now + t.UpdatedAt = now + } + return nil +} + +func (t *Token) BeforeUpdate(db orm.DB) error { + t.UpdatedAt = time.Now() + return nil +} diff --git a/templates/email/auth/loginToken.html b/templates/email/auth/loginToken.html new file mode 100644 index 0000000..25f64f8 --- /dev/null +++ b/templates/email/auth/loginToken.html @@ -0,0 +1,12 @@ +{{define "loginToken"}} +{{template "header"}} + +

Hello {{.Name}},

+

Click here to login with this device

+

Login Now

+

Or enter this token on login page from another device:

+

{{.Token}}

+

Link and token are valid one time only for next {{.Expiry | formatAsDuration}}.

+ +{{template "footer"}} +{{end}} \ No newline at end of file diff --git a/templates/email/footer.html b/templates/email/footer.html new file mode 100644 index 0000000..3b4f601 --- /dev/null +++ b/templates/email/footer.html @@ -0,0 +1,11 @@ +{{define "footer"}} + + + + + + +{{end}} \ No newline at end of file diff --git a/templates/email/header.html b/templates/email/header.html new file mode 100644 index 0000000..6d23004 --- /dev/null +++ b/templates/email/header.html @@ -0,0 +1,15 @@ +{{define "header"}} + + + + + + + +{{template "styles"}} + + + + +{{end}} \ No newline at end of file diff --git a/templates/email/styles.html b/templates/email/styles.html new file mode 100644 index 0000000..7b3f55e --- /dev/null +++ b/templates/email/styles.html @@ -0,0 +1,39 @@ +{{define "styles"}} + +{{end}} \ No newline at end of file diff --git a/testing/mock/authStore.go b/testing/mock/authStore.go new file mode 100644 index 0000000..10d5c9f --- /dev/null +++ b/testing/mock/authStore.go @@ -0,0 +1,61 @@ +package mock + +import "github.com/dhax/go-base/models" + +type AuthStore struct { + GetByIDFn func(id int) (*models.Account, error) + GetByIDInvoked bool + + GetByEmailFn func(email string) (*models.Account, error) + GetByEmailInvoked bool + + GetByRefreshTokenFn func(token string) (*models.Account, *models.Token, error) + GetByRefreshTokenInvoked bool + + UpdateAccountFn func(a *models.Account) error + UpdateAccountInvoked bool + + SaveRefreshTokenFn func(u *models.Token) error + SaveRefreshTokenInvoked bool + + DeleteRefreshTokenFn func(t *models.Token) error + DeleteRefreshTokenInvoked bool + + PurgeExpiredTokenFn func() error + PurgeExpiredTokenInvoked bool +} + +func (s *AuthStore) GetByID(id int) (*models.Account, error) { + s.GetByIDInvoked = true + return s.GetByIDFn(id) +} + +func (s *AuthStore) GetByEmail(email string) (*models.Account, error) { + s.GetByEmailInvoked = true + return s.GetByEmailFn(email) +} + +func (s *AuthStore) GetByRefreshToken(token string) (*models.Account, *models.Token, error) { + s.GetByRefreshTokenInvoked = true + return s.GetByRefreshTokenFn(token) +} + +func (s *AuthStore) UpdateAccount(a *models.Account) error { + s.UpdateAccountInvoked = true + return s.UpdateAccountFn(a) +} + +func (s *AuthStore) SaveRefreshToken(u *models.Token) error { + s.SaveRefreshTokenInvoked = true + return s.SaveRefreshTokenFn(u) +} + +func (s *AuthStore) DeleteRefreshToken(t *models.Token) error { + s.DeleteRefreshTokenInvoked = true + return s.DeleteRefreshTokenFn(t) +} + +func (s *AuthStore) PurgeExpiredToken() error { + s.PurgeExpiredTokenInvoked = true + return s.PurgeExpiredTokenFn() +} diff --git a/testing/mock/emailService.go b/testing/mock/emailService.go new file mode 100644 index 0000000..f0feaff --- /dev/null +++ b/testing/mock/emailService.go @@ -0,0 +1,13 @@ +package mock + +import "github.com/dhax/go-base/email" + +type EmailService struct { + LoginTokenFn func(name, email string, c email.LoginTokenContent) error + LoginTokenInvoked bool +} + +func (s *EmailService) LoginToken(n, e string, c email.LoginTokenContent) error { + s.LoginTokenInvoked = true + return s.LoginTokenFn(n, e, c) +} diff --git a/testing/rest/admin/accounts.http b/testing/rest/admin/accounts.http new file mode 100644 index 0000000..e634d8f --- /dev/null +++ b/testing/rest/admin/accounts.http @@ -0,0 +1,44 @@ +GET {{host}}/admin +Authorization: {{jwtAdmin}} +### +POST {{host}}/admin/accounts +Authorization: {{jwtAdmin}} + +{ + "email": "user@boot.io", + "name": "duplicate user", + "active": true, + "roles": ["user"] +} +### +GET {{host}}/admin/accounts/2 +Authorization: {{jwtAdmin}} +### +PUT {{host}}/admin/accounts/2 +Authorization: {{jwtAdmin}} + +{ + "email": "user@boot.io", + "name": "TEST USER", + "roles": ["user"] +} +### +DELETE {{host}}/admin/accounts/2 +Authorization: {{jwtAdmin}} +### +POST {{host}}/admin/accounts +Authorization: {{jwtAdmin}} + +{ + "email": "{{$timestamp}}@mail.io", + "name": "test user", + "active": true, + "roles": ["admin","user"] +} +### +GET {{host}}/admin/accounts?limit=3&page=1&order=id desc +Authorization: {{jwtAdmin}} +### +GET {{host}}/admin/accounts?email__match=%@boot.io +Authorization: {{jwtAdmin}} +### \ No newline at end of file diff --git a/testing/rest/user/account.http b/testing/rest/user/account.http new file mode 100644 index 0000000..ffd139a --- /dev/null +++ b/testing/rest/user/account.http @@ -0,0 +1,33 @@ +GET {{host}}/api/account +Authorization: {{jwtUser}} +### +PUT {{host}}/api/account +Authorization: {{jwtUser}} + +{ + "email": "user@boot.io", + "name": "User Boot", + "active": false, + "roles": ["user", "admin"] +} +### +DELETE {{host}}/api/account +Authorization: {{jwtUser}} +### +PUT {{host}}/api/account/token/3 +Authorization: {{jwtUser}} + +{ + "identifier": "my token identifier" +} +### +DELETE {{host}}/api/account/token/1 +Authorization: {{jwtUser}} +### +PUT {{host}}/api/account/profile +Authorization: {{jwtUser}} + +{ + "id": 3, + "theme": "dark" +} \ No newline at end of file diff --git a/testing/rest/user/profile.http b/testing/rest/user/profile.http new file mode 100644 index 0000000..eb9da14 --- /dev/null +++ b/testing/rest/user/profile.http @@ -0,0 +1,15 @@ + +GET {{host}}/api/profile +Authorization: {{jwtUser}} +### +PUT {{host}}/api/profile +Authorization: {{jwtUser}} + +{ + "email": "invalid", + "name": "test" +} +### +DELETE {{host}}/api/profile +Authorization: {{jwtUser}} +###