diff options
author | Felix Hanley <felix@userspace.com.au> | 2020-02-26 03:42:46 +0000 |
---|---|---|
committer | Felix Hanley <felix@userspace.com.au> | 2020-02-26 03:42:46 +0000 |
commit | f4b5128283bb4a40c97a1020b39fd662196fa132 (patch) | |
tree | 5f2d83b2b220bf033a94fc8df4d37f172f6be4b6 | |
parent | d6869c3f614257c2c96a5e9066158a8c9ac4cd06 (diff) | |
download | sws-f4b5128283bb4a40c97a1020b39fd662196fa132.tar.gz sws-f4b5128283bb4a40c97a1020b39fd662196fa132.tar.bz2 |
Add user auth and flashes
-rw-r--r-- | cmd/server/auth.go | 73 | ||||
-rw-r--r-- | cmd/server/flash.go | 82 | ||||
-rw-r--r-- | cmd/server/handlers.go | 27 | ||||
-rw-r--r-- | cmd/server/helpers.go | 2 | ||||
-rw-r--r-- | cmd/server/main.go | 112 | ||||
-rw-r--r-- | cmd/server/sites.go | 26 | ||||
-rw-r--r-- | go.mod | 4 | ||||
-rw-r--r-- | go.sum | 49 | ||||
-rw-r--r-- | sql/sqlite3/03_users.sql | 16 | ||||
-rw-r--r-- | static/default.css | 24 | ||||
-rw-r--r-- | store.go | 5 | ||||
-rw-r--r-- | store/sqlite3.go | 26 | ||||
-rw-r--r-- | templates/flash.tmpl | 9 | ||||
-rw-r--r-- | templates/layouts/base.tmpl | 6 | ||||
-rw-r--r-- | templates/layouts/public.tmpl | 4 | ||||
-rw-r--r-- | templates/login.tmpl | 16 | ||||
-rw-r--r-- | templates/navbar.tmpl | 11 | ||||
-rw-r--r-- | templates/site.tmpl | 2 | ||||
-rw-r--r-- | user.go | 82 |
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 @@ -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 @@ -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; @@ -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" . }} @@ -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 +} |