1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

refactor: use cookie-based login

feat: add login page
This commit is contained in:
Ferdinand Mütsch 2020-05-24 13:41:19 +02:00
parent d3ab54f6dc
commit 9697bb5fd5
19 changed files with 449 additions and 219 deletions

3
go.mod
View File

@ -6,6 +6,9 @@ require (
github.com/codegangsta/negroni v1.0.0 github.com/codegangsta/negroni v1.0.0
github.com/gobuffalo/packr/v2 v2.8.0 github.com/gobuffalo/packr/v2 v2.8.0
github.com/gorilla/mux v1.7.3 github.com/gorilla/mux v1.7.3
github.com/gorilla/schema v1.1.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.0
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
github.com/jinzhu/gorm v1.9.11 github.com/jinzhu/gorm v1.9.11
github.com/joho/godotenv v1.3.0 github.com/joho/godotenv v1.3.0

6
go.sum
View File

@ -129,6 +129,12 @@ github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/schema v1.1.0 h1:CamqUDOFUBqzrvxuz2vEwo8+SUdwsluFh7IlzJh30LY=
github.com/gorilla/schema v1.1.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=

40
main.go
View File

@ -4,6 +4,7 @@ import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"github.com/gorilla/securecookie"
"io/ioutil" "io/ioutil"
"log" "log"
"net/http" "net/http"
@ -97,6 +98,12 @@ func readConfig() *models.Config {
colors[strings.ToLower(k)] = v.Color colors[strings.ToLower(k)] = v.Color
} }
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
secureCookie := securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)
return &models.Config{ return &models.Config{
Env: env, Env: env,
Port: port, Port: port,
@ -109,6 +116,7 @@ func readConfig() *models.Config {
DbDialect: dbType, DbDialect: dbType,
DbMaxConn: dbMaxConn, DbMaxConn: dbMaxConn,
CleanUp: cleanUp, CleanUp: cleanUp,
SecureCookie: secureCookie,
DefaultUserName: defaultUserName, DefaultUserName: defaultUserName,
DefaultUserPassword: defaultUserPassword, DefaultUserPassword: defaultUserPassword,
CustomLanguages: customLangs, CustomLanguages: customLangs,
@ -154,8 +162,8 @@ func main() {
summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc} summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc}
aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc} aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc}
services := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc} svcs := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc}
for _, s := range services { for _, s := range svcs {
s.Init() s.Init()
} }
@ -167,16 +175,13 @@ func main() {
} }
// Handlers // Handlers
heartbeatHandler := &routes.HeartbeatHandler{HeartbeatSrvc: heartbeatSrvc} heartbeatHandler := routes.NewHeartbeatHandler(config, heartbeatSrvc)
summaryHandler := &routes.SummaryHandler{SummarySrvc: summarySrvc} summaryHandler := routes.NewSummaryHandler(config, summarySrvc)
healthHandler := &routes.HealthHandler{Db: db} healthHandler := routes.NewHealthHandler(db)
indexHandler := routes.NewIndexHandler(config, userSrvc)
// Middlewares // Middlewares
authenticateMiddleware := &middlewares.AuthenticateMiddleware{ authenticateMiddleware := middlewares.NewAuthenticateMiddleware(config, userSrvc, []string{"/api/health"})
UserSrvc: userSrvc,
WhitelistPaths: []string{"/api/health"},
}
basicAuthMiddleware := &middlewares.RequireBasicAuthMiddleware{}
corsMiddleware := cors.New(cors.Options{ corsMiddleware := cors.New(cors.Options{
AllowedOrigins: []string{"*"}, AllowedOrigins: []string{"*"},
AllowedHeaders: []string{"*"}, AllowedHeaders: []string{"*"},
@ -186,10 +191,16 @@ func main() {
// Setup Routing // Setup Routing
router := mux.NewRouter() router := mux.NewRouter()
mainRouter := mux.NewRouter().PathPrefix("/").Subrouter() mainRouter := mux.NewRouter().PathPrefix("/").Subrouter()
summaryRouter := mux.NewRouter().PathPrefix("/summary").Subrouter()
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
// Main Routes // Main Routes
mainRouter.Path("/").Methods(http.MethodGet).HandlerFunc(summaryHandler.Index) mainRouter.Path("/").Methods(http.MethodGet).HandlerFunc(indexHandler.Index)
mainRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(indexHandler.Login)
mainRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(indexHandler.Logout)
// Summary Routes
summaryRouter.Path("").Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
// API Routes // API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost) apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
@ -207,9 +218,12 @@ func main() {
negroni.Wrap(apiRouter), negroni.Wrap(apiRouter),
)) ))
router.PathPrefix("/").Handler(negroni.Classic().With( router.PathPrefix("/summary").Handler(negroni.Classic().With(
negroni.HandlerFunc(basicAuthMiddleware.Handle),
negroni.HandlerFunc(authenticateMiddleware.Handle), negroni.HandlerFunc(authenticateMiddleware.Handle),
negroni.Wrap(summaryRouter),
))
router.PathPrefix("/").Handler(negroni.Classic().With(
negroni.Wrap(mainRouter), negroni.Wrap(mainRouter),
)) ))

View File

@ -2,12 +2,9 @@ package middlewares
import ( import (
"context" "context"
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors" "errors"
"github.com/muety/wakapi/utils"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@ -18,25 +15,23 @@ import (
) )
type AuthenticateMiddleware struct { type AuthenticateMiddleware struct {
UserSrvc *services.UserService config *models.Config
Cache *cache.Cache userSrvc *services.UserService
WhitelistPaths []string cache *cache.Cache
Initialized bool whitelistPaths []string
} }
func (m *AuthenticateMiddleware) Init() { func NewAuthenticateMiddleware(config *models.Config, userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
if m.Cache == nil { return &AuthenticateMiddleware{
m.Cache = cache.New(1*time.Hour, 2*time.Hour) config: config,
userSrvc: userService,
cache: cache.New(1*time.Hour, 2*time.Hour),
whitelistPaths: whitelistPaths,
} }
m.Initialized = true
} }
func (m *AuthenticateMiddleware) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { func (m *AuthenticateMiddleware) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
if !m.Initialized { for _, p := range m.whitelistPaths {
m.Init()
}
for _, p := range m.WhitelistPaths {
if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p { if strings.HasPrefix(r.URL.Path, p) || r.URL.Path == p {
next(w, r) next(w, r)
return return
@ -44,81 +39,66 @@ func (m *AuthenticateMiddleware) Handle(w http.ResponseWriter, r *http.Request,
} }
var user *models.User var user *models.User
var userKey string user, err := m.tryGetUserByCookie(r)
user, userKey, err := m.tryGetUserByPassword(r)
if err != nil { if err != nil {
user, userKey, err = m.tryGetUserByApiKey(r) user, err = m.tryGetUserByApiKey(r)
} }
if err != nil { if err != nil {
w.WriteHeader(http.StatusUnauthorized) if strings.HasPrefix(r.URL.Path, "/api") {
w.WriteHeader(http.StatusUnauthorized)
} else {
utils.ClearCookie(w, models.AuthCookieKey)
http.Redirect(w, r, "/?error=unauthorized", http.StatusFound)
}
return return
} }
m.Cache.Set(userKey, user, cache.DefaultExpiration) m.cache.Set(user.ID, user, cache.DefaultExpiration)
ctx := context.WithValue(r.Context(), models.UserKey, user) ctx := context.WithValue(r.Context(), models.UserKey, user)
next(w, r.WithContext(ctx)) next(w, r.WithContext(ctx))
} }
func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, string, error) { func (m *AuthenticateMiddleware) tryGetUserByApiKey(r *http.Request) (*models.User, error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ") key, err := utils.ExtractBearerAuth(r)
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return nil, "", errors.New("failed to extract API key")
}
key, err := base64.StdEncoding.DecodeString(authHeader[1])
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
var user *models.User var user *models.User
userKey := strings.TrimSpace(string(key)) userKey := strings.TrimSpace(key)
cachedUser, ok := m.Cache.Get(userKey) cachedUser, ok := m.cache.Get(userKey)
if !ok { if !ok {
user, err = m.UserSrvc.GetUserByKey(userKey) user, err = m.userSrvc.GetUserByKey(userKey)
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
} else { } else {
user = cachedUser.(*models.User) user = cachedUser.(*models.User)
} }
return user, userKey, nil return user, nil
} }
func (m *AuthenticateMiddleware) tryGetUserByPassword(r *http.Request) (*models.User, string, error) { func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.User, error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ") login, err := utils.ExtractCookieAuth(r, m.config)
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return nil, "", errors.New("failed to extract API key")
}
hash, err := base64.StdEncoding.DecodeString(authHeader[1])
userKey := strings.TrimSpace(string(hash))
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
var user *models.User var user *models.User
cachedUser, ok := m.Cache.Get(userKey) cachedUser, ok := m.cache.Get(login.Username)
if !ok { if !ok {
re := regexp.MustCompile(`^(.+):(.+)$`) user, err = m.userSrvc.GetUserById(login.Username)
groups := re.FindAllStringSubmatch(userKey, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return nil, "", errors.New("failed to parse user agent string")
}
userId, password := groups[0][1], groups[0][2]
user, err = m.UserSrvc.GetUserById(userId)
if err != nil { if err != nil {
return nil, "", err return nil, err
} }
passwordHash := md5.Sum([]byte(password)) if !utils.CheckPassword(user, login.Password) {
passwordHashString := hex.EncodeToString(passwordHash[:]) return nil, errors.New("invalid password")
if passwordHashString != user.Password {
return nil, "", errors.New("invalid password")
} }
} else { } else {
user = cachedUser.(*models.User) user = cachedUser.(*models.User)
} }
return user, userKey, nil return user, nil
} }

View File

@ -1,14 +0,0 @@
package middlewares
import (
"net/http"
)
type RequireBasicAuthMiddleware struct{}
func (m *RequireBasicAuthMiddleware) Init() {}
func (m *RequireBasicAuthMiddleware) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
next(w, r)
}

View File

@ -1,21 +1,26 @@
package models package models
import "github.com/gorilla/securecookie"
type Config struct { type Config struct {
Env string Env string
Port int Port int
Addr string Addr string
DbHost string DbHost string
DbPort uint DbPort uint
DbUser string DbUser string
DbPassword string DbPassword string
DbName string DbName string
DbDialect string DbDialect string
DbMaxConn uint DbMaxConn uint
CleanUp bool CleanUp bool
DefaultUserName string DefaultUserName string
DefaultUserPassword string DefaultUserPassword string
CustomLanguages map[string]string SecureCookieHashKey string
LanguageColors map[string]string SecureCookieBlockKey string
CustomLanguages map[string]string
LanguageColors map[string]string
SecureCookie *securecookie.SecureCookie
} }
func (c *Config) IsDev() bool { func (c *Config) IsDev() bool {

View File

@ -1,5 +1,6 @@
package models package models
const ( const (
UserKey = "user" UserKey = "user"
AuthCookieKey = "wakapi_auth"
) )

View File

@ -39,4 +39,5 @@ type SummaryItemContainer struct {
type SummaryViewModel struct { type SummaryViewModel struct {
*Summary *Summary
LanguageColors map[string]string LanguageColors map[string]string
Error string
} }

View File

@ -5,3 +5,8 @@ type User struct {
ApiKey string `json:"api_key" gorm:"unique"` ApiKey string `json:"api_key" gorm:"unique"`
Password string `json:"-"` Password string `json:"-"`
} }
type Login struct {
Username string `json:"username"`
Password string `json:"password"`
}

43
routes/common.go Normal file
View File

@ -0,0 +1,43 @@
package routes
import (
"fmt"
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
"path"
)
func init() {
loadTemplates()
}
var templates map[string]*template.Template
func loadTemplates() {
tplPath := "views"
tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
})
templates = make(map[string]*template.Template)
files, err := ioutil.ReadDir(tplPath)
if err != nil {
panic(err)
}
for _, file := range files {
tplName := file.Name()
if file.IsDir() || path.Ext(tplName) != ".html" {
continue
}
tpl, err := tpls.New(tplName).ParseFiles(fmt.Sprintf("%s/%s", tplPath, tplName))
if err != nil {
panic(err)
}
templates[tplName] = tpl
}
}

View File

@ -7,12 +7,16 @@ import (
) )
type HealthHandler struct { type HealthHandler struct {
Db *gorm.DB db *gorm.DB
}
func NewHealthHandler(db *gorm.DB) *HealthHandler {
return &HealthHandler{db: db}
} }
func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) { func (h *HealthHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
var dbStatus int var dbStatus int
if err := h.Db.DB().Ping(); err == nil { if err := h.db.DB().Ping(); err == nil {
dbStatus = 1 dbStatus = 1
} }

View File

@ -12,15 +12,18 @@ import (
) )
type HeartbeatHandler struct { type HeartbeatHandler struct {
HeartbeatSrvc *services.HeartbeatService config *models.Config
heartbeatSrvc *services.HeartbeatService
}
func NewHeartbeatHandler(config *models.Config, heartbearService *services.HeartbeatService) *HeartbeatHandler {
return &HeartbeatHandler{
config: config,
heartbeatSrvc: heartbearService,
}
} }
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) { func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
var heartbeats []*models.Heartbeat var heartbeats []*models.Heartbeat
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent")) opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
@ -37,7 +40,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
hb.Editor = editor hb.Editor = editor
hb.User = user hb.User = user
hb.UserID = user.ID hb.UserID = user.ID
hb.Augment(h.HeartbeatSrvc.Config.CustomLanguages) hb.Augment(h.config.CustomLanguages)
if !hb.Valid() { if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@ -46,7 +49,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
} }
} }
if err := h.HeartbeatSrvc.InsertBatch(heartbeats); err != nil { if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
os.Stderr.WriteString(err.Error()) os.Stderr.WriteString(err.Error())
return return

104
routes/index.go Normal file
View File

@ -0,0 +1,104 @@
package routes
import (
"github.com/gorilla/schema"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
)
type IndexHandler struct {
config *models.Config
userSrvc *services.UserService
}
var loginDecoder = schema.NewDecoder()
func NewIndexHandler(config *models.Config, userService *services.UserService) *IndexHandler {
return &IndexHandler{
config: config,
userSrvc: userService,
}
}
func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, "/summary", http.StatusFound)
return
}
if err := r.URL.Query().Get("error"); err != "" {
if err == "unauthorized" {
respondError(w, err, http.StatusUnauthorized)
} else {
respondError(w, err, http.StatusInternalServerError)
}
return
}
templates["index.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
var login models.Login
if err := r.ParseForm(); err != nil {
respondError(w, "missing parameters", http.StatusBadRequest)
return
}
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
respondError(w, "missing parameters", http.StatusBadRequest)
return
}
user, err := h.userSrvc.GetUserById(login.Username)
if err != nil {
respondError(w, "resource not found", http.StatusNotFound)
return
}
if !utils.CheckPassword(user, login.Password) {
respondError(w, "invalid credentials", http.StatusUnauthorized)
return
}
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondError(w, "internal server error", http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: true,
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/summary", http.StatusFound)
}
func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
utils.ClearCookie(w, models.AuthCookieKey)
http.Redirect(w, r, "/", http.StatusFound)
}
func respondError(w http.ResponseWriter, error string, status int) {
w.WriteHeader(status)
templates["index.tpl.html"].Execute(w, struct {
Error string
}{Error: error})
}

View File

@ -2,11 +2,7 @@ package routes
import ( import (
"errors" "errors"
"fmt"
"html/template"
"io/ioutil"
"net/http" "net/http"
"path"
"time" "time"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
@ -24,55 +20,19 @@ const (
) )
type SummaryHandler struct { type SummaryHandler struct {
SummarySrvc *services.SummaryService cummarySrvc *services.SummaryService
Initialized bool config *models.Config
templates map[string]*template.Template
} }
func (m *SummaryHandler) Init() { func NewSummaryHandler(config *models.Config, summaryService *services.SummaryService) *SummaryHandler {
m.loadTemplates() return &SummaryHandler{
m.Initialized = true cummarySrvc: summaryService,
} config: config,
func (m *SummaryHandler) loadTemplates() {
tplPath := "views"
templates := template.New("").Funcs(template.FuncMap{
"json": utils.Json,
"date": utils.FormatDateHuman,
})
m.templates = make(map[string]*template.Template)
files, err := ioutil.ReadDir(tplPath)
if err != nil {
panic(err)
}
for _, file := range files {
tplName := file.Name()
if file.IsDir() || path.Ext(tplName) != ".html" {
continue
}
tpl, err := templates.New(tplName).ParseFiles(fmt.Sprintf("%s/%s", tplPath, tplName))
if err != nil {
panic(err)
}
m.templates[tplName] = tpl
} }
} }
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) { func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { summary, err, status := loadUserSummary(r, h.cummarySrvc)
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if !h.Initialized {
h.Init()
}
summary, err, status := loadUserSummary(r, h.SummarySrvc)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -83,17 +43,8 @@ func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
} }
func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) { func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if h.config.IsDev() {
w.WriteHeader(http.StatusMethodNotAllowed) loadTemplates()
return
}
if !h.Initialized {
h.Init()
}
if h.SummarySrvc.Config.IsDev() {
h.loadTemplates()
} }
q := r.URL.Query() q := r.URL.Query()
@ -102,7 +53,7 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()
} }
summary, err, status := loadUserSummary(r, h.SummarySrvc) summary, err, status := loadUserSummary(r, h.cummarySrvc)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -111,10 +62,10 @@ func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{ vm := models.SummaryViewModel{
Summary: summary, Summary: summary,
LanguageColors: utils.FilterLanguageColors(h.SummarySrvc.Config.LanguageColors, summary), LanguageColors: utils.FilterLanguageColors(h.config.LanguageColors, summary),
} }
h.templates["index.tpl.html"].Execute(w, vm) templates["summary.tpl.html"].Execute(w, vm)
} }
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) { func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) {

View File

@ -138,7 +138,7 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
return summaries, nil return summaries, nil
} }
// Will return *models.Summary objects with only user_id and to_time filled // Will return *models.Index objects with only user_id and to_time filled
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) { func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
var summaries []*models.Summary var summaries []*models.Summary
if err := srv.Db. if err := srv.Db.

65
utils/auth.go Normal file
View File

@ -0,0 +1,65 @@
package utils
import (
"crypto/md5"
"encoding/base64"
"encoding/hex"
"errors"
"github.com/muety/wakapi/models"
"net/http"
"regexp"
"strings"
)
func ExtractBasicAuth(r *http.Request) (username, password string, err error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return username, password, errors.New("failed to extract API key")
}
hash, err := base64.StdEncoding.DecodeString(authHeader[1])
userKey := strings.TrimSpace(string(hash))
if err != nil {
return username, password, err
}
re := regexp.MustCompile(`^(.+):(.+)$`)
groups := re.FindAllStringSubmatch(userKey, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return username, password, errors.New("failed to parse user agent string")
}
username, password = groups[0][1], groups[0][2]
return username, password, err
}
func ExtractBearerAuth(r *http.Request) (key string, err error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeader) != 2 || authHeader[0] != "Basic" {
return key, errors.New("failed to extract API key")
}
keyBytes, err := base64.StdEncoding.DecodeString(authHeader[1])
return string(keyBytes), err
}
func ExtractCookieAuth(r *http.Request, config *models.Config) (login *models.Login, err error) {
cookie, err := r.Cookie(models.AuthCookieKey)
if err != nil {
return nil, errors.New("missing authentication")
}
if err := config.SecureCookie.Decode(models.AuthCookieKey, cookie.Value, &login); err != nil {
return nil, errors.New("invalid parameters")
}
return login, nil
}
func CheckPassword(user *models.User, password string) bool {
passwordHash := md5.Sum([]byte(password))
passwordHashString := hex.EncodeToString(passwordHash[:])
if passwordHashString == user.Password {
return true
}
return false
}

View File

@ -12,3 +12,13 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
} }
} }
func ClearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
Secure: true,
HttpOnly: true,
})
}

View File

@ -3,54 +3,36 @@
{{ template "head.tpl.html" . }} {{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1> <h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1>
</div> </div>
<div class="text-white text-sm flex items-center justify-center mt-4">
<a href="?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
<a href="?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
<a href="?interval=week" class="m-1 border-b border-green-700">This Week</a>
<a href="?interval=month" class="m-1 border-b border-green-700">This Month</a>
<a href="?interval=year" class="m-1 border-b border-green-700">This Year</a>
<a href="?interval=any" class="m-1 border-b border-green-700">All Time</a>
</div>
<main class="mt-10 flex-grow" id="grid-container">
<div class="flex justify-center">
<div class="p-1">
<div class="flex justify-center p-4 bg-white rounded shadow">
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span title="End Time">{{ .ToTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span id="total-span" title="Total Hours"></span></p>
</div>
</div>
</div>
<div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
<canvas id="chart-projects"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
<canvas id="chart-os"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
<canvas id="chart-language"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
<canvas id="chart-editor"></canvas>
</div>
</div>
</div>
</main>
{{ template "footer.tpl.html" . }} {{ template "alerts.tpl.html" . }}
{{ template "foot.tpl.html" . }} <main class="mt-10 flex-grow flex justify-center w-full" id="grid-container">
<div class="flex-grow max-w-lg mt-12">
<form action="/login" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="text" id="username"
name="username" placeholder="Enter your username ..." required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" type="password" id="password"
name="password" placeholder="******" required>
</div>
<div class="flex justify-between">
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
</div>
</form>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body> </body>
</html> </html>

67
views/summary.tpl.html Normal file
View File

@ -0,0 +1,67 @@
<html>
{{ template "head.tpl.html" . }}
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="absolute top-0 right-0 mr-8 mt-10 py-2">
<form action="/logout" method="post">
<button type="submit" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Logout</button>
</form>
</div>
<div class="flex items-center justify-center">
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
</div>
<div class="text-white text-sm flex items-center justify-center mt-4">
<a href="?interval=today" class="m-1 border-b border-green-700">Today (live)</a>
<a href="?interval=day" class="m-1 border-b border-green-700">Yesterday</a>
<a href="?interval=week" class="m-1 border-b border-green-700">This Week</a>
<a href="?interval=month" class="m-1 border-b border-green-700">This Month</a>
<a href="?interval=year" class="m-1 border-b border-green-700">This Year</a>
<a href="?interval=any" class="m-1 border-b border-green-700">All Time</a>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow" id="grid-container">
<div class="flex justify-center">
<div class="p-1">
<div class="flex justify-center p-4 bg-white rounded shadow">
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span title="End Time">{{ .ToTime | date }}</span></p>
<p class="mx-2"><strong></strong> <span id="total-span" title="Total Hours"></span></p>
</div>
</div>
</div>
<div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px">
<canvas id="chart-projects"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px">
<canvas id="chart-os"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px">
<canvas id="chart-language"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px">
<canvas id="chart-editor"></canvas>
</div>
</div>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>