aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFelix Hanley <felix@userspace.com.au>2020-02-26 03:42:46 +0000
committerFelix Hanley <felix@userspace.com.au>2020-02-26 03:42:46 +0000
commitf4b5128283bb4a40c97a1020b39fd662196fa132 (patch)
tree5f2d83b2b220bf033a94fc8df4d37f172f6be4b6
parentd6869c3f614257c2c96a5e9066158a8c9ac4cd06 (diff)
downloadsws-f4b5128283bb4a40c97a1020b39fd662196fa132.tar.gz
sws-f4b5128283bb4a40c97a1020b39fd662196fa132.tar.bz2
Add user auth and flashes
-rw-r--r--cmd/server/auth.go73
-rw-r--r--cmd/server/flash.go82
-rw-r--r--cmd/server/handlers.go27
-rw-r--r--cmd/server/helpers.go2
-rw-r--r--cmd/server/main.go112
-rw-r--r--cmd/server/sites.go26
-rw-r--r--go.mod4
-rw-r--r--go.sum49
-rw-r--r--sql/sqlite3/03_users.sql16
-rw-r--r--static/default.css24
-rw-r--r--store.go5
-rw-r--r--store/sqlite3.go26
-rw-r--r--templates/flash.tmpl9
-rw-r--r--templates/layouts/base.tmpl6
-rw-r--r--templates/layouts/public.tmpl4
-rw-r--r--templates/login.tmpl16
-rw-r--r--templates/navbar.tmpl11
-rw-r--r--templates/site.tmpl2
-rw-r--r--user.go82
19 files changed, 485 insertions, 91 deletions
diff --git a/cmd/server/auth.go b/cmd/server/auth.go
new file mode 100644
index 0000000..06973d3
--- /dev/null
+++ b/cmd/server/auth.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "time"
+
+ jwt "github.com/dgrijalva/jwt-go"
+ "src.userspace.com.au/sws"
+)
+
+func handleAuth(db sws.UserStore, rndr Renderer) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ email := r.PostFormValue("email")
+ password := r.PostFormValue("password")
+
+ if email == "" || password == "" {
+ //httpError(w, 406, "bad auth")
+ r = flashSet(r, flashError, "invalid credentials")
+ http.Redirect(w, flashQuery(r), loginURL, http.StatusSeeOther)
+ return
+ }
+
+ debug("authing email", email)
+
+ user, err := db.GetUserByEmail(email)
+ if err != nil || user == nil {
+ //httpError(w, 404, err.Error())
+ r = flashSet(r, flashError, "invalid user")
+ http.Redirect(w, flashQuery(r), loginURL, http.StatusSeeOther)
+ return
+ }
+
+ if !user.Enabled {
+ debug("user", email, "is disabled")
+ //httpError(w, 403, "forbidden")
+ r = flashSet(r, flashError, "access denied")
+ http.Redirect(w, flashQuery(r), loginURL, http.StatusSeeOther)
+ return
+ }
+
+ if err := user.ValidPassword(password); err != nil {
+ //httpError(w, 401, err.Error())
+ r = flashSet(r, flashError, "authentication failed")
+ http.Redirect(w, flashQuery(r), loginURL, http.StatusSeeOther)
+ return
+ }
+ debug("user", email, "is authed")
+
+ expiry := time.Now().Add(time.Hour)
+
+ _, t, err := tokenAuth.Encode(jwt.MapClaims{
+ "user_id": *user.ID,
+ "exp": expiry.Unix(),
+ })
+ if err != nil {
+ httpError(w, 500, err.Error())
+ return
+ }
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "jwt",
+ Value: t,
+ HttpOnly: true,
+ Path: "/",
+ //Secure: true,
+ Expires: expiry,
+ })
+ r = r.WithContext(context.WithValue(r.Context(), "user", user))
+ r = flashSet(r, flashSuccess, "authenticated successfully")
+ http.Redirect(w, r, flashURL(r, "/sites"), http.StatusSeeOther)
+ }
+}
diff --git a/cmd/server/flash.go b/cmd/server/flash.go
new file mode 100644
index 0000000..ce0ed04
--- /dev/null
+++ b/cmd/server/flash.go
@@ -0,0 +1,82 @@
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+type flashLvl string
+
+type flashCtxKey string
+
+const (
+ flashInfo flashLvl = "info"
+ flashError flashLvl = "error"
+ flashWarn flashLvl = "warn"
+ flashSuccess flashLvl = "success"
+
+ flashQueryKey = "_flash"
+)
+
+type flashMsg struct {
+ Level flashLvl
+ Message string
+}
+
+func flashSet(r *http.Request, l flashLvl, m string) *http.Request {
+ debug("fetching existing flashes")
+ flashes := flashGet(r)
+ debug("fetched existing flashes", flashes)
+ if flashes == nil {
+ flashes = make([]flashMsg, 0)
+ }
+ flashes = append(flashes, flashMsg{l, m})
+ debug("adding flashes to context")
+ return r.WithContext(context.WithValue(r.Context(), flashCtxKey("flash"), flashes))
+}
+
+func flashGet(r *http.Request) []flashMsg {
+ if msg := r.URL.Query().Get(flashQueryKey); msg != "" {
+ if b, err := base64.RawURLEncoding.DecodeString(msg); err == nil {
+ debug("found flash from query", string(b))
+ var f []flashMsg
+ if err = json.Unmarshal(b, &f); err == nil {
+ return f
+ }
+ }
+ }
+ if f, ok := r.Context().Value(flashCtxKey("flash")).([]flashMsg); ok {
+ debug("found flash from context", f)
+ return f
+ }
+
+ return nil
+}
+
+func flashURL(r *http.Request, url string) string {
+ f := flashGet(r)
+ b, err := json.Marshal(f)
+ if err != nil {
+ return url
+ }
+ qs := base64.RawURLEncoding.EncodeToString(b)
+ vals := r.URL.Query()
+ vals.Set(flashQueryKey, qs)
+ return fmt.Sprintf("%s?%s", url, vals.Encode())
+}
+
+func flashQuery(r *http.Request) *http.Request {
+ f := flashGet(r)
+ b, err := json.Marshal(f)
+ if err != nil {
+ return nil
+ }
+ qs := base64.RawURLEncoding.EncodeToString(b)
+ vals := r.URL.Query()
+ vals.Set("_flash", qs)
+ r.URL.RawQuery = vals.Encode()
+ return r
+}
diff --git a/cmd/server/handlers.go b/cmd/server/handlers.go
index e47fd30..85e3b48 100644
--- a/cmd/server/handlers.go
+++ b/cmd/server/handlers.go
@@ -2,12 +2,37 @@ package main
import (
"net/http"
+ "time"
+
+ "src.userspace.com.au/sws"
)
+type templateData struct {
+ User *sws.User
+ Flashes []flashMsg
+ Begin *time.Time
+ End *time.Time
+ Site *sws.Site
+ Sites []*sws.Site
+ Pages *sws.PageSet
+ UserAgents *sws.UserAgentSet
+ Hits *sws.HitSet
+}
+
+func newTemplateData(r *http.Request) *templateData {
+ out := &templateData{Flashes: flashGet(r)}
+ log(out)
+ if user := r.Context().Value("user"); user != nil {
+ out.User = user.(*sws.User)
+ }
+ return out
+}
+
func handleIndex(rndr Renderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
- if err := rndr.Render(w, "home", nil); err != nil {
+ payload := newTemplateData(r)
+ if err := rndr.Render(w, "home", payload); err != nil {
log(err)
http.Error(w, http.StatusText(500), 500)
}
diff --git a/cmd/server/helpers.go b/cmd/server/helpers.go
index 9db82cf..38f2a19 100644
--- a/cmd/server/helpers.go
+++ b/cmd/server/helpers.go
@@ -35,7 +35,7 @@ var funcMap = template.FuncMap{
func httpError(w http.ResponseWriter, code int, msg string) {
log(msg)
- http.Error(w, http.StatusText(500), 500)
+ http.Error(w, http.StatusText(code), code)
}
func extractTimeRange(r *http.Request) (*time.Time, *time.Time) {
diff --git a/cmd/server/main.go b/cmd/server/main.go
index edf0af3..5cdc201 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -14,6 +14,7 @@ import (
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
+ "github.com/go-chi/jwtauth"
_ "github.com/jackc/pgx/stdlib"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
@@ -25,9 +26,14 @@ import (
var (
Version string
log, debug sws.Logger
+ tokenAuth *jwtauth.JWTAuth
)
-const endpoint = "//stats.userspace.com.au/sws.gif"
+const (
+ endpoint = "//stats.userspace.com.au/sws.gif"
+ loginURL = "/login"
+ logoutURL = "/logout"
+)
// Flags
var (
@@ -46,6 +52,8 @@ func init() {
// Default to no log
log = func(v ...interface{}) {}
debug = func(v ...interface{}) {}
+
+ tokenAuth = jwtauth.New("HS256", []byte("lkjasd0f9u203ijsldkfj"), nil)
}
type Renderer interface {
@@ -100,10 +108,15 @@ func main() {
st = store.NewSqlite3Store(db)
}
+ tmplsCommon := []string{"flash.tmpl", "navbar.tmpl"}
+ tmplsAuthed := append(tmplsCommon, []string{"layouts/base.tmpl", "charts.tmpl"}...)
+ tmplsPublic := append(tmplsCommon, "layouts/public.tmpl")
+
tmpls, err := LoadHTMLTemplateMap(map[string][]string{
- "sites": []string{"layouts/base.tmpl", "sites.tmpl", "charts.tmpl"},
- "site": []string{"layouts/base.tmpl", "site.tmpl", "charts.tmpl"},
- "home": []string{"layouts/public.tmpl", "home.tmpl"},
+ "sites": append(tmplsAuthed, "sites.tmpl"),
+ "site": append(tmplsAuthed, "site.tmpl"),
+ "home": append(tmplsPublic, "home.tmpl"),
+ "login": append(tmplsPublic, "login.tmpl"),
"example": []string{"example.tmpl"},
}, funcMap)
if err != nil {
@@ -125,6 +138,7 @@ func main() {
r.Use(middleware.Recoverer)
siteCtx := getSiteCtx(st)
+ userCtx := getUserCtx(st)
// For counter
r.Get("/sws.js", handleCounter(*addr))
@@ -132,17 +146,36 @@ func main() {
// For UI
r.Get("/hits", handleHits(st))
- r.Route("/sites", func(r chi.Router) {
- r.Get("/", handleSites(st, renderer))
- r.Route("/{siteID}", func(r chi.Router) {
- r.Use(siteCtx)
- r.Get("/", handleSite(st, renderer))
- r.Route("/sparklines", func(r chi.Router) {
- r.Get("/{b:\\d+}-{e:\\d+}.svg", sparklineHandler(st))
+
+ // Authed routes
+ r.Group(func(r chi.Router) {
+ r.Use(jwtauth.Verifier(tokenAuth))
+ r.Use(jwtauth.Authenticator)
+ r.Use(userCtx)
+ r.Get(logoutURL, func(w http.ResponseWriter, r *http.Request) {
+ http.SetCookie(w, &http.Cookie{
+ Name: "jwt",
+ Value: "",
+ HttpOnly: true,
+ Path: "/",
+ //Secure: true,
+ Expires: time.Time{},
})
- r.Route("/charts", func(r chi.Router) {
- r.Get("/{b:\\d+}-{e:\\d+}.svg", svgChartHandler(st))
- r.Get("/{b:\\d+}-{e:\\d+}.png", svgChartHandler(st))
+ r = flashSet(r, flashSuccess, "de-authenticated successfully")
+ http.Redirect(w, r, flashURL(r, "/"), http.StatusSeeOther)
+ })
+ r.Route("/sites", func(r chi.Router) {
+ r.Get("/", handleSites(st, renderer))
+ r.Route("/{siteID}", func(r chi.Router) {
+ r.Use(siteCtx)
+ r.Get("/", handleSite(st, renderer))
+ r.Route("/sparklines", func(r chi.Router) {
+ r.Get("/{b:\\d+}-{e:\\d+}.svg", sparklineHandler(st))
+ })
+ r.Route("/charts", func(r chi.Router) {
+ r.Get("/{b:\\d+}-{e:\\d+}.svg", svgChartHandler(st))
+ r.Get("/{b:\\d+}-{e:\\d+}.png", svgChartHandler(st))
+ })
})
})
})
@@ -150,8 +183,22 @@ func main() {
// Example
r.Get("/test.html", handleExample(renderer))
+ authHandler := handleAuth(st, renderer)
+
+ // Public routes
r.Route("/", func(r chi.Router) {
r.Get("/", handleIndex(renderer))
+ r.Get(loginURL, func(w http.ResponseWriter, r *http.Request) {
+ flash := flashGet(r)
+ if err := renderer.Render(w, "login", map[string]interface{}{
+ "Flash": flash,
+ }); err != nil {
+ httpError(w, 500, err.Error())
+ return
+ }
+ return
+ })
+ r.Post(loginURL, authHandler)
r.Get("/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
@@ -184,11 +231,32 @@ func getSiteCtx(db sws.SiteStore) func(http.Handler) http.Handler {
}
}
-// func getAuthCtx(db sws.UserStore) func(http.Handler) http.Handler {
-// return func(next http.Handler) http.Handler {
-// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-// ctx := context.WithValue(r.Context(), "user", user)
-// next.ServeHTTP(w, r.WithContext(ctx))
-// })
-// }
-// }
+func getUserCtx(db sws.UserStore) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, claims, err := jwtauth.FromContext(r.Context())
+ if err != nil {
+ log("missing claims")
+ http.Redirect(w, r, flashURL(r, "/login"), http.StatusUnauthorized)
+ return
+ }
+
+ id, ok := claims["user_id"]
+ if !ok {
+ log("missing user_id")
+ http.Redirect(w, r, flashURL(r, "/login"), http.StatusUnauthorized)
+ return
+ }
+
+ user, err := db.GetUserByID(int(id.(float64)))
+ if err != nil {
+ log("missing user")
+ http.Redirect(w, r, flashURL(r, "/login"), http.StatusUnauthorized)
+ return
+ }
+ log("found user, adding to context")
+ ctx := context.WithValue(r.Context(), "user", user)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
diff --git a/cmd/server/sites.go b/cmd/server/sites.go
index 01de666..9f5c58c 100644
--- a/cmd/server/sites.go
+++ b/cmd/server/sites.go
@@ -14,11 +14,10 @@ func handleSites(db sws.SiteStore, rndr Renderer) http.HandlerFunc {
httpError(w, 500, err.Error())
return
}
- payload := struct {
- Sites []*sws.Site
- }{
- Sites: sites,
- }
+
+ payload := newTemplateData(r)
+ payload.Sites = sites
+
if err := rndr.Render(w, "sites", payload); err != nil {
httpError(w, 500, err.Error())
return
@@ -54,17 +53,12 @@ func handleSite(db sws.SiteStore, rndr Renderer) http.HandlerFunc {
pageSet := sws.NewPageSet(hitSet)
uaSet := sws.NewUserAgentSet(hitSet)
- payload := struct {
- Site *sws.Site
- Pages sws.PageSet
- UserAgents sws.UserAgentSet
- Hits *sws.HitSet
- }{
- Site: site,
- Pages: pageSet,
- UserAgents: uaSet,
- Hits: hitSet,
- }
+ payload := newTemplateData(r)
+ payload.Site = site
+ payload.Pages = &pageSet
+ payload.UserAgents = &uaSet
+ payload.Hits = hitSet
+
if err := rndr.Render(w, "site", payload); err != nil {
httpError(w, 500, err.Error())
return
diff --git a/go.mod b/go.mod
index e3cf3a8..4c081ca 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,9 @@ module src.userspace.com.au/sws
require (
github.com/blend/go-sdk v2.0.0+incompatible // indirect
github.com/cockroachdb/apd v1.1.0 // indirect
+ github.com/dgrijalva/jwt-go v3.2.0+incompatible
github.com/go-chi/chi v4.0.3+incompatible
+ github.com/go-chi/jwtauth v4.0.4+incompatible
github.com/gofrs/uuid v3.2.0+incompatible // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 // indirect
@@ -15,7 +17,7 @@ require (
github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 // indirect
github.com/speps/go-hashids v2.0.0+incompatible
github.com/wcharczuk/go-chart v2.0.1+incompatible
- golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6 // indirect
+ golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 // indirect
google.golang.org/appengine v1.6.5 // indirect
src.userspace.com.au/go-migrate v0.0.0-20200208102934-cf11cf76db3f
diff --git a/go.sum b/go.sum
index 5045aae..00aae8f 100644
--- a/go.sum
+++ b/go.sum
@@ -1,85 +1,46 @@
-github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
-github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
-github.com/antchfx/htmlquery v1.0.0/go.mod h1:MS9yksVSQXls00iXkiMqXr0J+umL/AmxXKuP28SUJM8=
-github.com/antchfx/xmlquery v1.0.0/go.mod h1:/+CnyD/DzHRnv2eRxrVbieRU/FIF6N0C+7oTtyUtCKk=
-github.com/antchfx/xpath v1.0.0/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/blend/go-sdk v2.0.0+incompatible h1:FL9X/of4ZYO5D2JJNI4vHrbXPfuSDbUa7h8JP9+E92w=
github.com/blend/go-sdk v2.0.0+incompatible/go.mod h1:3GUb0YsHFNTJ6hsJTpzdmCUl05o8HisKjx5OAlzYKdw=
-github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
-github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/go-chi/chi v4.0.3+incompatible h1:gakN3pDJnzZN5jqFV2TEdF66rTfKeITyR8qu6ekICEY=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/go-chi/chi v4.0.3+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
-github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk=
+github.com/go-chi/jwtauth v4.0.4+incompatible h1:LGIxg6YfvSBzxU2BljXbrzVc1fMlgqSKBQgKOGAVtPY=
+github.com/go-chi/jwtauth v4.0.4+incompatible/go.mod h1:Q5EIArY/QnD6BdS+IyDw7B2m6iNbnPxtfd6/BcmtWbs=
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/gocolly/colly/v2 v2.0.1/go.mod h1:ePrRZlJcLTU2C/f8pJzXfkdBtBDHL5hOaKLcBoiJcq8=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/jackc/fake v0.0.0-20150926172116-812a484cc733 h1:vr3AYkKovP8uR8AvSGGUK1IDqRa5lAAvEkZG1LKaCRc=
github.com/jackc/fake v0.0.0-20150926172116-812a484cc733/go.mod h1:WrMFNQdiFJ80sQsxDoMokWK1W5TQtxBFNpzWTD84ibQ=
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
-github.com/jawher/mow.cli v1.1.0/go.mod h1:aNaQlc7ozF3vw6IJ2dHjp2ZFiA4ozMIYY6PyuRJwlUg=
-github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA=
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
-github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/mssola/user_agent v0.5.1 h1:sJUCUozh+j7c0dR2zMIUX5aJjoY/TNo/gXiNujoH5oY=
github.com/mssola/user_agent v0.5.1/go.mod h1:TTPno8LPY3wAIEKRpAtkdMT0f8SE24pLRGPahjCH4uw=
-github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852/go.mod h1:eqOVx5Vwu4gd2mmMZvVZsgIqNSaW3xxRThUJ0k/TPk4=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
-github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114 h1:Pm6R878vxWWWR+Sa3ppsLce/Zq+JNTs6aVvRu13jv9A=
github.com/shopspring/decimal v0.0.0-20200105231215-408a2507e114/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
-github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw=
github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
-github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
-github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/temoto/robotstxt v1.1.1/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
-github.com/velebak/colly-sqlite3-storage v0.0.0-20190425160637-c76683d5163d/go.mod h1:+bhXpKXsxEKgCb9gcKEHSQr6XtqlcIoklUyeZMGS4Fw=
github.com/wcharczuk/go-chart v2.0.1+incompatible h1:0pz39ZAycJFF7ju/1mepnk26RLVLBCWz1STcD3doU0A=
github.com/wcharczuk/go-chart v2.0.1+incompatible/go.mod h1:PF5tmL4EIx/7Wf+hEkpCqYi5He4u90sw+0+6FhrryuE=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6 h1:Sy5bstxEqwwbYs6n0/pBuxKENqOeZUgD45Gp3Q3pqLg=
golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg=
golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
-golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
-google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
-google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
src.userspace.com.au/go-migrate v0.0.0-20200208102934-cf11cf76db3f h1:Vdn/5kMeLXWfkXF/wm9lioSBASBn02aA5DeVjLAYjLM=
src.userspace.com.au/go-migrate v0.0.0-20200208102934-cf11cf76db3f/go.mod h1:QaSWOcvGubR8TBPs8XgLN67muYgAKnmIgHAaQk5ZR1c=
diff --git a/sql/sqlite3/03_users.sql b/sql/sqlite3/03_users.sql
new file mode 100644
index 0000000..b332c9c
--- /dev/null
+++ b/sql/sqlite3/03_users.sql
@@ -0,0 +1,16 @@
+create table users (
+ id integer primary key autoincrement,
+ email varchar not null,
+ first_name varchar not null,
+ last_name varchar not null,
+ enabled integer not null default 0,
+ pw_hash varchar not null,
+ pw_salt varchar not null,
+ last_login_at timestamp null,
+ created_at timestamp not null,
+ updated_at timestamp not null
+);
+create index "users#email" on users(email);
+
+insert into users (email, first_name, last_name, enabled, pw_hash, pw_salt, created_at, updated_at)
+values ('admin@example.com', 'Admin', 'User', 1, 'f1b4e14683c2ad53b3c798baeea6b17b7cf77b291825353cff3f24a255d98fe9', 'UvQgb6w0RjCOaM9L', date('now'), date('now'));
diff --git a/static/default.css b/static/default.css
index 209871c..5faf3e4 100644
--- a/static/default.css
+++ b/static/default.css
@@ -1,3 +1,9 @@
+*, *:before, *:after {
+ box-sizing: inherit;
+}
+html {
+ box-sizing: border-box;
+}
body {
background-color: #fffaf7;
display: flex;
@@ -41,6 +47,24 @@ main {
}
}
+header.site {
+ height: 50px;
+}
+
+.flashes {
+ display: block;
+ left: 0;
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 500px;
+ opacity: 0;
+ position: absolute;
+ right: 0;
+ text-align: center;
+ top: 50px;
+ transition: opacity 3s ease-in-out;
+}
+
.chart {
align-items: stretch;
display: flex;
diff --git a/store.go b/store.go
index 9b2ce4f..e4808a4 100644
--- a/store.go
+++ b/store.go
@@ -2,6 +2,7 @@ package sws
type Store interface {
SiteStore
+ UserStore
GetSiteByName(string) (*Site, error)
SaveHit(*Hit) error
}
@@ -23,3 +24,7 @@ type CounterStore interface {
SimpleSiteStore
SaveHit(*Hit) error
}
+type UserStore interface {
+ GetUserByID(int) (*User, error)
+ GetUserByEmail(string) (*User, error)
+}
diff --git a/store/sqlite3.go b/store/sqlite3.go
index 0dec7fb..33590fb 100644
--- a/store/sqlite3.go
+++ b/store/sqlite3.go
@@ -97,6 +97,22 @@ func (s *Sqlite3) SaveHit(h *sws.Hit) error {
return nil
}
+func (s *Sqlite3) GetUserByEmail(email string) (*sws.User, error) {
+ var u sws.User
+ if err := s.db.QueryRowx(stmts["userByEmail"], email).StructScan(&u); err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
+func (s *Sqlite3) GetUserByID(id int) (*sws.User, error) {
+ var u sws.User
+ if err := s.db.QueryRowx(stmts["userByID"], id).StructScan(&u); err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
func processFilter(sql *string, filter map[string]interface{}) {
if sql == nil {
panic("empty sql")
@@ -152,4 +168,14 @@ ua.hash as "ua.hash", ua.name as "ua.name", ua.last_seen_at as "ua.last_seen_at"
from hits h
join user_agents ua on h.user_agent_hash = ua.hash
where h.site_id = :site_id`,
+
+ "userByEmail": `select id, email, first_name, last_name, pw_hash, pw_salt, enabled,
+created_at, updated_at, last_login_at
+from users
+where email = $1`,
+
+ "userByID": `select id, email, first_name, last_name, pw_hash, pw_salt, enabled,
+created_at, updated_at, last_login_at
+from users
+where id = $1`,
}
diff --git a/templates/flash.tmpl b/templates/flash.tmpl
new file mode 100644
index 0000000..826320a
--- /dev/null
+++ b/templates/flash.tmpl
@@ -0,0 +1,9 @@
+{{ define "flash" }}
+ {{ if .Flashes }}
+ <div class="flashes">
+ {{ range .Flashes }}
+ <div class="flash {{ .Level }}">{{ .Message }}</div>
+ {{ end }}
+ </div>
+ {{ end }}
+{{ end }}
diff --git a/templates/layouts/base.tmpl b/templates/layouts/base.tmpl
index 1b7f9ad..8986909 100644
--- a/templates/layouts/base.tmpl
+++ b/templates/layouts/base.tmpl
@@ -6,17 +6,15 @@
<link rel="stylesheet" href="/default.css">
</head>
<body>
- <header class="site">
- </header>
+ {{ template "navbar" . }}
<div class="page">
<div class="sidebar">
<nav>
- <a href="/">Home</a>
<a href="/sites">Sites</a>
<a href="/sites/new">New site</a>
- <a href="/logout">Logout</a>
</nav>
</div>
+ {{ template "flash" . }}
{{ template "content" . }}
</div>
<footer></footer>
diff --git a/templates/layouts/public.tmpl b/templates/layouts/public.tmpl
index 98a03c2..eb3e64a 100644
--- a/templates/layouts/public.tmpl
+++ b/templates/layouts/public.tmpl
@@ -6,9 +6,9 @@
<link rel="stylesheet" href="/default.css">
</head>
<body>
- <header class="site">
- </header>
+ {{ template "navbar" . }}
<div class="page">
+ {{ template "flash" . }}
{{ template "content" . }}
</div>
<footer></footer>
diff --git a/templates/login.tmpl b/templates/login.tmpl
new file mode 100644
index 0000000..80badee
--- /dev/null
+++ b/templates/login.tmpl
@@ -0,0 +1,16 @@
+{{ define "content" }}
+ <main>
+ <h2>Login</h2>
+ <form method="post" action="/login">
+ <div class="field">
+ <input type="email" name="email" placeholder="your email" />
+ </div>
+ <div class="field">
+ <input type="password" name="password" placeholder="your password" />
+ </div>
+ <div class="field">
+ <input type="submit" />
+ </div>
+ </form>
+ </main>
+{{ end }}
diff --git a/templates/navbar.tmpl b/templates/navbar.tmpl
new file mode 100644
index 0000000..3c2733c
--- /dev/null
+++ b/templates/navbar.tmpl
@@ -0,0 +1,11 @@
+{{ define "navbar" }}
+ <header class="site">
+ <a class="logo" href="/"><img /></a>
+ {{ if .User }}
+ <a href="/sites">Sites</a>
+ <a class="logout" href="/logout">Logout</a>
+ {{ else }}
+ <a class="login" href="/login">Login</a>
+ {{ end }}
+ </header>
+{{ end }}
diff --git a/templates/site.tmpl b/templates/site.tmpl
index 44e0777..d46bbd5 100644
--- a/templates/site.tmpl
+++ b/templates/site.tmpl
@@ -22,7 +22,9 @@
</ul>
<h2>User Agents</h2>
{{ if .UserAgents }}
+ <fig>
{{ template "barChart" .UserAgents }}
+ </fig>
<ul class="agents">
{{ range .UserAgents }}
{{ template "uaForList" . }}
diff --git a/user.go b/user.go
new file mode 100644
index 0000000..78e3497
--- /dev/null
+++ b/user.go
@@ -0,0 +1,82 @@
+package sws
+
+import (
+ "crypto/rand"
+ "crypto/subtle"
+ "encoding/base64"
+ "fmt"
+ "time"
+
+ "golang.org/x/crypto/argon2"
+)
+
+type User struct {
+ ID *int `json:"id,omitempty"`
+ Email *string `json:"email,omitempty"`
+ FirstName *string `json:"first_name,omitempty" db:"first_name"`
+ LastName *string `json:"last_name,omitempty" db:"last_name"`
+ Enabled bool `json:"enabled"`
+ PwHash *string `json:"pw_hash" db:"pw_hash"`
+ PwSalt *string `json:"pw_salt" db:"pw_salt"`
+ LastLoginAt *time.Time `json:"last_login_at" db:"last_login_at"`
+ CreatedAt *time.Time `json:"created_at,omitempty" db:"created_at"`
+ UpdatedAt *time.Time `json:"updated_at,omitempty" db:"updated_at"`
+}
+
+const (
+ pwMemory = 64 * 1024
+ pwTime = 1
+ pwThreads = 4
+ pwLength = 32
+)
+
+func (u *User) SetPassword(pw string) error {
+ // Generate a Salt
+ saltB := make([]byte, 16)
+ if _, err := rand.Read(saltB); err != nil {
+ return err
+ }
+
+ hashB := generateHash(pw, saltB)
+
+ salt := base64.RawStdEncoding.EncodeToString(saltB)
+ hash := base64.RawStdEncoding.EncodeToString(hashB)
+
+ u.PwHash = &hash
+ u.PwSalt = &salt
+ return nil
+}
+
+func (u *User) ValidPassword(test string) error {
+ if u.PwHash == nil || u.PwSalt == nil {
+ return fmt.Errorf("invalid user")
+ }
+ var hash1B, hash2B, saltB []byte
+ var err error
+
+ if saltB, err = base64.RawStdEncoding.DecodeString(*u.PwSalt); err != nil {
+ return err
+ }
+
+ hash2B = generateHash(test, saltB)
+
+ if hash1B, err = base64.RawStdEncoding.DecodeString(*u.PwHash); err != nil {
+ return err
+ }
+ ok, err := comparePassword(hash1B, hash2B)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return fmt.Errorf("invalid")
+ }
+ return nil
+}
+
+func generateHash(password string, salt []byte) []byte {
+ return argon2.IDKey([]byte(password), salt, pwTime, pwMemory, pwThreads, pwLength)
+}
+
+func comparePassword(hash1B, hash2B []byte) (bool, error) {
+ return (subtle.ConstantTimeCompare(hash1B, hash2B) == 1), nil
+}