initial commit
This commit is contained in:
commit
93d8310491
46 changed files with 3379 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
37
README.md
Normal file
37
README.md
Normal file
|
|
@ -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
|
||||
|
||||
172
api/admin/accounts.go
Normal file
172
api/admin/accounts.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
52
api/admin/api.go
Normal file
52
api/admin/api.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
81
api/admin/errors.go
Normal file
81
api/admin/errors.go
Normal file
|
|
@ -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)}
|
||||
)
|
||||
113
api/api.go
Normal file
113
api/api.go
Normal file
|
|
@ -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"))
|
||||
})
|
||||
}
|
||||
225
api/app/account.go
Normal file
225
api/app/account.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
33
api/app/api.go
Normal file
33
api/app/api.go
Normal file
|
|
@ -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
|
||||
}
|
||||
81
api/app/errors.go
Normal file
81
api/app/errors.go
Normal file
|
|
@ -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)}
|
||||
)
|
||||
65
api/server.go
Normal file
65
api/server.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
91
auth/api.go
Normal file
91
auth/api.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
87
auth/authenticator.go
Normal file
87
auth/authenticator.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
31
auth/authorizer.go
Normal file
31
auth/authorizer.go
Normal file
|
|
@ -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
|
||||
}
|
||||
20
auth/crypto.go
Normal file
20
auth/crypto.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
64
auth/errors.go
Normal file
64
auth/errors.go
Normal file
|
|
@ -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)}
|
||||
)
|
||||
215
auth/handler.go
Normal file
215
auth/handler.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
346
auth/handler_test.go
Normal file
346
auth/handler_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
118
auth/jwt.go
Normal file
118
auth/jwt.go
Normal file
|
|
@ -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
|
||||
}
|
||||
89
auth/logintoken.go
Normal file
89
auth/logintoken.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
cmd/migrate.go
Normal file
60
cmd/migrate.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// Copyright © 2017 NAME HERE <EMAIL ADDRESS>
|
||||
//
|
||||
// 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!")
|
||||
}
|
||||
94
cmd/root.go
Normal file
94
cmd/root.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Copyright © 2017 NAME HERE <EMAIL ADDRESS>
|
||||
//
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
58
cmd/serve.go
Normal file
58
cmd/serve.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Copyright © 2017 NAME HERE <EMAIL ADDRESS>
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
66
database/accountStore.go
Normal file
66
database/accountStore.go
Normal file
|
|
@ -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
|
||||
}
|
||||
84
database/admAccountStore.go
Normal file
84
database/admAccountStore.go
Normal file
|
|
@ -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
|
||||
}
|
||||
84
database/authStore.go
Normal file
84
database/authStore.go
Normal file
|
|
@ -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
|
||||
}
|
||||
65
database/migrate/1_initial.go
Normal file
65
database/migrate/1_initial.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
48
database/migrate/2_bootstrap_users.go
Normal file
48
database/migrate/2_bootstrap_users.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
53
database/migrate/3_add_profile_table.go
Normal file
53
database/migrate/3_add_profile_table.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
70
database/migrate/main.go
Normal file
70
database/migrate/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
44
database/postgres.go
Normal file
44
database/postgres.go
Normal file
|
|
@ -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
|
||||
}
|
||||
24
email/auth.go
Normal file
24
email/auth.go
Normal file
|
|
@ -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
|
||||
}
|
||||
148
email/email.go
Normal file
148
email/email.go
Normal file
|
|
@ -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
|
||||
}
|
||||
121
logging/logger.go
Normal file
121
logging/logger.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
21
main.go
Normal file
21
main.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright © 2017 NAME HERE <EMAIL ADDRESS>
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
86
models/account.go
Normal file
86
models/account.go
Normal file
|
|
@ -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
|
||||
}
|
||||
42
models/profile.go
Normal file
42
models/profile.go
Normal file
|
|
@ -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")),
|
||||
)
|
||||
}
|
||||
33
models/token.go
Normal file
33
models/token.go
Normal file
|
|
@ -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
|
||||
}
|
||||
12
templates/email/auth/loginToken.html
Normal file
12
templates/email/auth/loginToken.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{{define "loginToken"}}
|
||||
{{template "header"}}
|
||||
|
||||
<p>Hello {{.Name}},</p>
|
||||
<p>Click here to login with this device</p>
|
||||
<p><a href="{{.URL}}" class="btn-primary">Login Now</a></p>
|
||||
<p>Or enter this token on login page from another device:</p>
|
||||
<p>{{.Token}}</p>
|
||||
<p>Link and token are valid one time only for next {{.Expiry | formatAsDuration}}.</p>
|
||||
|
||||
{{template "footer"}}
|
||||
{{end}}
|
||||
11
templates/email/footer.html
Normal file
11
templates/email/footer.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{{define "footer"}}
|
||||
|
||||
<div id="footer">
|
||||
<hr>
|
||||
<p>sent by GoBase</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{end}}
|
||||
15
templates/email/header.html
Normal file
15
templates/email/header.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{{define "header"}}
|
||||
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
|
||||
{{template "styles"}}
|
||||
|
||||
</head>
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage">
|
||||
|
||||
{{end}}
|
||||
39
templates/email/styles.html
Normal file
39
templates/email/styles.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{{define "styles"}}
|
||||
<style type="text/css">
|
||||
body {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: 'Helvetica Neue', Verdana, sans-serif;
|
||||
color: #107A94;
|
||||
}
|
||||
/* -------------------------------------
|
||||
LINKS & BUTTONS
|
||||
------------------------------------- */
|
||||
a {
|
||||
color: #348eda;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
text-decoration: none;
|
||||
color: #FFF;
|
||||
background-color: #348eda;
|
||||
border: solid #348eda;
|
||||
border-width: 10px 20px;
|
||||
line-height: 2em;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
border-radius: 5px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
footer {
|
||||
background-color: #8AB8CE;
|
||||
}
|
||||
|
||||
</style>
|
||||
{{end}}
|
||||
61
testing/mock/authStore.go
Normal file
61
testing/mock/authStore.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
13
testing/mock/emailService.go
Normal file
13
testing/mock/emailService.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
44
testing/rest/admin/accounts.http
Normal file
44
testing/rest/admin/accounts.http
Normal file
|
|
@ -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}}
|
||||
###
|
||||
33
testing/rest/user/account.http
Normal file
33
testing/rest/user/account.http
Normal file
|
|
@ -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"
|
||||
}
|
||||
15
testing/rest/user/profile.http
Normal file
15
testing/rest/user/profile.http
Normal file
|
|
@ -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}}
|
||||
###
|
||||
Loading…
Add table
Add a link
Reference in a new issue