initial commit

This commit is contained in:
dhax 2017-09-25 18:23:11 +02:00
commit 93d8310491
46 changed files with 3379 additions and 0 deletions

15
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

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

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

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

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

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

View file

@ -0,0 +1,11 @@
{{define "footer"}}
<div id="footer">
<hr>
<p>sent by GoBase</p>
</div>
</body>
</html>
{{end}}

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

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

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

View 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}}
###

View 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"
}

View 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}}
###