From 4a2fb7e82d5d617298cb28b66485fc6f30c55781 Mon Sep 17 00:00:00 2001
From: Julien Dessaux
Date: Wed, 21 Apr 2021 17:23:07 +0200
Subject: Reworked the webui package, added authentication feature and tests
---
cmd/trains-webui/main.go | 12 +++-
internal/webui/app.go | 78 -----------------------
internal/webui/html/base.html | 18 ++++++
internal/webui/html/index.html | 24 --------
internal/webui/html/login.html | 13 ++++
internal/webui/html/root.html | 15 +++++
internal/webui/login.go | 85 ++++++++++++++++++++++++++
internal/webui/login_test.go | 136 +++++++++++++++++++++++++++++++++++++++++
internal/webui/root.go | 58 ++++++++++++++++++
internal/webui/root_test.go | 54 ++++++++++++++++
internal/webui/session.go | 19 ++++++
internal/webui/utils.go | 60 ++++++++++++++++++
internal/webui/utils_test.go | 71 +++++++++++++++++++++
internal/webui/webui.go | 25 ++++++++
pkg/model/users.go | 10 +--
15 files changed, 570 insertions(+), 108 deletions(-)
delete mode 100644 internal/webui/app.go
create mode 100644 internal/webui/html/base.html
delete mode 100644 internal/webui/html/index.html
create mode 100644 internal/webui/html/login.html
create mode 100644 internal/webui/html/root.html
create mode 100644 internal/webui/login.go
create mode 100644 internal/webui/login_test.go
create mode 100644 internal/webui/root.go
create mode 100644 internal/webui/root_test.go
create mode 100644 internal/webui/session.go
create mode 100644 internal/webui/utils.go
create mode 100644 internal/webui/utils_test.go
create mode 100644 internal/webui/webui.go
diff --git a/cmd/trains-webui/main.go b/cmd/trains-webui/main.go
index 0ea3b57..c33cbbf 100644
--- a/cmd/trains-webui/main.go
+++ b/cmd/trains-webui/main.go
@@ -7,6 +7,7 @@ import (
"git.adyxax.org/adyxax/trains/internal/webui"
"git.adyxax.org/adyxax/trains/pkg/config"
+ "git.adyxax.org/adyxax/trains/pkg/database"
)
func main() {
@@ -23,5 +24,14 @@ func main() {
if err != nil {
log.Fatal(err)
}
- webui.Run(c)
+
+ db, err := database.InitDB("sqlite3", "file:test.db?_foreign_keys=on")
+ if err != nil {
+ log.Fatal(err)
+ }
+ if err := db.Migrate(); err != nil {
+ log.Fatal(err)
+ }
+
+ webui.Run(c, db)
}
diff --git a/internal/webui/app.go b/internal/webui/app.go
deleted file mode 100644
index f6082dc..0000000
--- a/internal/webui/app.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package webui
-
-import (
- "embed"
- "html/template"
- "log"
- "net/http"
- "time"
-
- "git.adyxax.org/adyxax/trains/pkg/config"
- "git.adyxax.org/adyxax/trains/pkg/navitia_api_client"
-)
-
-// the api client object
-var client *navitia_api_client.Client
-
-// the webui configuration
-var conf *config.Config
-
-//go:embed html/*
-var templatesFS embed.FS
-
-//go:embed static/*
-var staticFS embed.FS
-
-// The page template variable
-type Page struct {
- Departures []Departure
- Title string
-}
-type Departure struct {
- DisplayName string
- Arrival string
- Odd bool
-}
-
-// The root handler of the webui
-func rootHandler(w http.ResponseWriter, r *http.Request) {
- if r.URL.Path == "/" {
- var p Page
- if d, err := client.GetDepartures(conf.TrainStop); err != nil {
- log.Printf("%+v\n%s\n", d, err)
- } else {
- for i := 0; i < len(d.Departures); i++ {
- t, err := time.Parse("20060102T150405", d.Departures[i].StopDateTime.ArrivalDateTime)
- if err != nil {
- panic(err)
- }
- p.Departures = append(p.Departures, Departure{d.Departures[i].DisplayInformations.Direction, t.Format("Mon, 02 Jan 2006 15:04:05"), i%2 == 1})
- }
- w.Header().Set("Cache-Control", "no-store, no-cache")
- }
- p.Title = "Horaires des prochains trains à Crépieux la Pape"
- renderTemplate(w, "index", p)
- } else {
- http.Error(w, "404 Not Found", http.StatusNotFound)
- }
-}
-
-var templates = template.Must(template.ParseFS(templatesFS, "html/index.html"))
-
-func renderTemplate(w http.ResponseWriter, tmpl string, p Page) {
- err := templates.ExecuteTemplate(w, tmpl+".html", p)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
-}
-
-func Run(c *config.Config) {
- conf = c
- client = navitia_api_client.NewClient(c.Token)
- http.Handle("/static/", http.FileServer(http.FS(staticFS)))
- http.HandleFunc("/", rootHandler)
-
- listenStr := c.Address + ":" + c.Port
- log.Printf("Starting webui on %s", listenStr)
- log.Fatal(http.ListenAndServe(listenStr, nil))
-}
diff --git a/internal/webui/html/base.html b/internal/webui/html/base.html
new file mode 100644
index 0000000..7522e94
--- /dev/null
+++ b/internal/webui/html/base.html
@@ -0,0 +1,18 @@
+{{ define "base" }}
+
+
+
+
+ {{ .Title }}
+
+
+
+
+
+
+
+ {{ template "main" . }}
+
+
+
+{{ end }}
diff --git a/internal/webui/html/index.html b/internal/webui/html/index.html
deleted file mode 100644
index a0a7d82..0000000
--- a/internal/webui/html/index.html
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
- {{ .Title }}
-
-
-
-
-
-
- Horaires des prochains trains à Crépieux la Pape
-
-
- Arrivée en gare | Direction |
-
-
- {{ range .Departures }}
- {{ .Arrival }} | {{ .DisplayName }} |
- {{ end }}
-
-
-
-
diff --git a/internal/webui/html/login.html b/internal/webui/html/login.html
new file mode 100644
index 0000000..33e9f02
--- /dev/null
+++ b/internal/webui/html/login.html
@@ -0,0 +1,13 @@
+{{ template "base" . }}
+
+{{ define "main" }}
+
+{{ end }}
diff --git a/internal/webui/html/root.html b/internal/webui/html/root.html
new file mode 100644
index 0000000..a293ee7
--- /dev/null
+++ b/internal/webui/html/root.html
@@ -0,0 +1,15 @@
+{{ template "base" . }}
+
+{{ define "main" }}
+Horaires des prochains trains à Crépieux la Pape
+
+
+ Arrivée en gare | Direction |
+
+
+ {{ range .Departures }}
+ {{ .Arrival }} | {{ .DisplayName }} |
+ {{ end }}
+
+
+{{ end }}
diff --git a/internal/webui/login.go b/internal/webui/login.go
new file mode 100644
index 0000000..c0eb109
--- /dev/null
+++ b/internal/webui/login.go
@@ -0,0 +1,85 @@
+package webui
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "regexp"
+
+ "git.adyxax.org/adyxax/trains/pkg/database"
+ "git.adyxax.org/adyxax/trains/pkg/model"
+)
+
+const sessionCookieName = "session-trains-webui"
+
+var validUsername = regexp.MustCompile(`^[a-zA-Z]\w*$`)
+var validPassword = regexp.MustCompile(`^.+$`)
+
+var loginTemplate = template.Must(template.ParseFS(templatesFS, "html/base.html", "html/login.html"))
+
+// The login handler of the webui
+func loginHandler(e *env, w http.ResponseWriter, r *http.Request) error {
+ if r.URL.Path == "/login" {
+ _, err := tryAndResumeSession(e, r)
+ if err == nil {
+ // already logged in
+ http.Redirect(w, r, "/", http.StatusFound)
+ return nil
+ }
+ if r.Method == http.MethodPost {
+ r.ParseForm()
+ // username
+ username, ok := r.Form["username"]
+ if !ok {
+ return newStatusError(http.StatusBadRequest, fmt.Errorf("No username field in POST"))
+ }
+ if len(username) != 1 {
+ return newStatusError(http.StatusBadRequest, fmt.Errorf("Invalid multiple username fields in POST"))
+ }
+ if ok := validUsername.MatchString(username[0]); !ok {
+ return newStatusError(http.StatusBadRequest, fmt.Errorf("Invalid username field in POST"))
+ }
+ // password
+ password, ok := r.Form["password"]
+ if !ok {
+ return newStatusError(http.StatusBadRequest, fmt.Errorf("No password field in POST"))
+ }
+ if len(password) != 1 {
+ return newStatusError(http.StatusBadRequest, fmt.Errorf("Invalid multiple password fields in POST"))
+ }
+ if ok := validPassword.MatchString(password[0]); !ok {
+ return newStatusError(http.StatusBadRequest, fmt.Errorf("Invalid password field in POST"))
+ }
+ // try to login
+ user, err := e.dbEnv.Login(&model.UserLogin{Username: username[0], Password: password[0]})
+ if err != nil {
+ switch e := err.(type) {
+ case *database.PasswordError:
+ // TODO : handle in page
+ return e
+ case *database.QueryError:
+ return e
+ default:
+ return e
+ }
+ }
+ token, err := e.dbEnv.CreateSession(user)
+ if err != nil {
+ return newStatusError(http.StatusInternalServerError, err)
+ }
+ cookie := http.Cookie{Name: sessionCookieName, Value: *token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 3600000}
+ http.SetCookie(w, &cookie)
+ http.Redirect(w, r, "/", http.StatusFound)
+ return nil
+ } else {
+ p := Page{Title: "Login"}
+ err := loginTemplate.ExecuteTemplate(w, "login.html", p)
+ if err != nil {
+ return newStatusError(http.StatusInternalServerError, err)
+ }
+ return nil
+ }
+ } else {
+ return newStatusError(http.StatusNotFound, fmt.Errorf("Invalid path in loginHandler"))
+ }
+}
diff --git a/internal/webui/login_test.go b/internal/webui/login_test.go
new file mode 100644
index 0000000..562095f
--- /dev/null
+++ b/internal/webui/login_test.go
@@ -0,0 +1,136 @@
+package webui
+
+import (
+ "net/http"
+ "net/url"
+ "testing"
+
+ "git.adyxax.org/adyxax/trains/pkg/database"
+ "git.adyxax.org/adyxax/trains/pkg/model"
+ "github.com/stretchr/testify/require"
+)
+
+func TestLoginHandler(t *testing.T) {
+ // test environment setup
+ dbEnv, err := database.InitDB("sqlite3", "file::memory:?_foreign_keys=on")
+ require.Nil(t, err)
+ err = dbEnv.Migrate()
+ require.Nil(t, err)
+ user1, err := dbEnv.CreateUser(&model.UserRegistration{Username: "user1", Password: "password1", Email: "julien@adyxax.org"})
+ require.Nil(t, err)
+ _, err = dbEnv.Login(&model.UserLogin{Username: "user1", Password: "password1"})
+ require.Nil(t, err)
+ token1, err := dbEnv.CreateSession(user1)
+ require.Nil(t, err)
+ e := &env{dbEnv: dbEnv}
+ // test GET requests
+ runHttpTest(t, e, loginHandler, &httpTestCase{
+ name: "a simple get should display the login page",
+ input: httpTestInput{
+ method: http.MethodGet,
+ path: "/login",
+ },
+ expect: httpTestExpect{
+ code: http.StatusOK,
+ bodyString: "