From 74d02b00bdff1bc649d9f72e83da04e0e8a4a260 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Else Date: Tue, 15 Dec 2020 17:40:14 +0100 Subject: [PATCH] Login form with JWT --- authentication.go | 111 +++++++++++++++++++++++++++++++ config.go | 4 ++ go.mod | 3 +- go.sum | 4 +- http.go | 8 +-- render.go | 1 + templates/assets/css/styles.css | 2 +- templates/assets/css/styles.scss | 2 +- templates/login.gohtml | 22 ++++++ templates/strings/default.yaml | 5 +- 10 files changed, 150 insertions(+), 12 deletions(-) create mode 100644 authentication.go create mode 100644 templates/login.gohtml diff --git a/authentication.go b/authentication.go new file mode 100644 index 0000000..bbe488b --- /dev/null +++ b/authentication.go @@ -0,0 +1,111 @@ +package main + +import ( + "bytes" + "encoding/base64" + "io" + "io/ioutil" + "net/http" + "time" + + "github.com/dgrijalva/jwt-go" +) + +func checkCredentials(username, password string) bool { + return username == appConfig.User.Nick && password == appConfig.User.Password +} + +func jwtKey() []byte { + return []byte(appConfig.Server.JWTSecret) +} + +func authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + success := func() { + if acceptLogin(w) { + next.ServeHTTP(w, r) + } + } + // 1. Check basic auth + username, password, basicauth := r.BasicAuth() + if basicauth && checkCredentials(username, password) { + success() + return + } + // 2. Check JWT + if tokenCookie, err := r.Cookie("token"); err == nil { + if tkn, err := jwt.Parse(tokenCookie.Value, func(t *jwt.Token) (interface{}, error) { + return jwtKey(), nil + }); err == nil && tkn.Valid { + success() + return + } + } + // 3. Show login form + w.WriteHeader(http.StatusUnauthorized) + h, _ := json.Marshal(r.Header.Clone()) + b, _ := ioutil.ReadAll(io.LimitReader(r.Body, 2000000)) // Only allow 20 Megabyte + _ = r.Body.Close() + if len(b) == 0 { + // Maybe it's a form + _ = r.ParseForm() + b = []byte(r.PostForm.Encode()) + } + render(w, templateLogin, &renderData{ + Data: map[string]string{ + "loginmethod": r.Method, + "loginheaders": base64.StdEncoding.EncodeToString(h), + "loginbody": base64.StdEncoding.EncodeToString(b), + }, + }) + }) +} + +func checkIsLogin(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + if !checkLogin(rw, r) { + next.ServeHTTP(rw, r) + } + }) +} + +func checkLogin(w http.ResponseWriter, r *http.Request) bool { + if r.Method == http.MethodPost && + r.Header.Get(contentType) == contentTypeWWWForm && + r.FormValue("loginaction") == "login" && + checkCredentials(r.FormValue("username"), r.FormValue("password")) { + // Do original request + loginbody, _ := base64.StdEncoding.DecodeString(r.FormValue("loginbody")) + req, _ := http.NewRequest(r.FormValue("loginmethod"), r.RequestURI, bytes.NewReader(loginbody)) + // Copy original headers + loginheaders, _ := base64.StdEncoding.DecodeString(r.FormValue("loginheaders")) + var headers http.Header + json.Unmarshal(loginheaders, &headers) + for k, v := range headers { + req.Header[k] = v + } + // Set basic auth + req.SetBasicAuth(r.FormValue("username"), r.FormValue("password")) + // Serve original request + d.ServeHTTP(w, req) + return true + } + return false +} + +func acceptLogin(w http.ResponseWriter) bool { + expiration := time.Now().Add(7 * 24 * time.Hour) + tokenString, err := jwt.NewWithClaims(jwt.SigningMethodHS256, &jwt.StandardClaims{ExpiresAt: expiration.Unix()}).SignedString(jwtKey()) + if err != nil { + http.Error(w, "failed to sign JWT", http.StatusInternalServerError) + return false + } + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: tokenString, + Expires: expiration, + Secure: true, + HttpOnly: true, + }) + return true +} diff --git a/config.go b/config.go index 08e59ad..bb0c167 100644 --- a/config.go +++ b/config.go @@ -31,6 +31,7 @@ type configServer struct { PublicAddress string `mapstructure:"publicAddress"` PublicHTTPS bool `mapstructure:"publicHttps"` LetsEncryptMail string `mapstructure:"letsEncryptMail"` + JWTSecret string `mapstructure:"jwtSecret"` } type configDb struct { @@ -212,6 +213,9 @@ func initConfig() error { if appConfig.Server.Domain == "" { return errors.New("no domain configured") } + if appConfig.Server.JWTSecret == "" { + return errors.New("no JWT secret configured") + } if len(appConfig.Blogs) == 0 { return errors.New("no blog configured") } diff --git a/go.mod b/go.mod index c86be57..fa53773 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/araddon/dateparse v0.0.0-20201001162425-8aadafed4dc4 github.com/caddyserver/certmagic v0.12.0 github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/fastly/go-utils v0.0.0-20180712184237-d95a45783239 // indirect github.com/go-chi/chi v4.1.2+incompatible github.com/go-fed/httpsig v1.0.1-0.20200711113112-812070f75b67 @@ -59,7 +60,7 @@ require ( golang.org/x/mod v0.4.0 // indirect golang.org/x/net v0.0.0-20201209123823-ac852fbbde11 // indirect golang.org/x/sync v0.0.0-20201207232520-09787c993a3a - golang.org/x/sys v0.0.0-20201214095126-aec9a390925b // indirect + golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e // indirect golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect golang.org/x/text v0.3.4 // indirect golang.org/x/tools v0.0.0-20201211185031-d93e913c1a58 // indirect diff --git a/go.sum b/go.sum index 9b88490..3b37762 100644 --- a/go.sum +++ b/go.sum @@ -458,8 +458,8 @@ golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c h1:UIcGWL6/wpCfyGuJnRFJRurA+ golang.org/x/sys v0.0.0-20200724161237-0e2f3a69832c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201214095126-aec9a390925b h1:tv7/y4pd+sR8bcNb2D6o7BNU6zjWm0VjQLac+w7fNNM= -golang.org/x/sys v0.0.0-20201214095126-aec9a390925b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e h1:AyodaIpKjppX+cBfTASF2E1US3H2JFBj920Ot3rtDjs= +golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/http.go b/http.go index d9f9318..220262e 100644 --- a/http.go +++ b/http.go @@ -33,15 +33,10 @@ const ( ) var ( - d *dynamicHandler - authMiddleware func(next http.Handler) http.Handler + d *dynamicHandler ) func startServer() (err error) { - // Init - authMiddleware = middleware.BasicAuth("", map[string]string{ - appConfig.User.Nick: appConfig.User.Password, - }) // Start d = &dynamicHandler{} err = reloadRouter() @@ -85,6 +80,7 @@ func buildHandler() (http.Handler, error) { if !appConfig.Cache.Enable { r.Use(middleware.NoCache) } + r.Use(checkIsLogin) // Profiler if appConfig.Server.Debug { diff --git a/render.go b/render.go index bf903aa..83e2f6a 100644 --- a/render.go +++ b/render.go @@ -31,6 +31,7 @@ const templateSearch = "search" const templateSummary = "summary" const templatePhotosSummary = "photosummary" const templateEditor = "editor" +const templateLogin = "login" var templates map[string]*template.Template var templateFunctions template.FuncMap diff --git a/templates/assets/css/styles.css b/templates/assets/css/styles.css index 1dc6f72..ac2863c 100644 --- a/templates/assets/css/styles.css +++ b/templates/assets/css/styles.css @@ -127,7 +127,7 @@ footer * { display: none; } -.fw, .fw-form, .fw-form input:not([type]), .fw-form input[type=text], .fw-form input[type=email], .fw-form input[type=url], .fw-form textarea { +.fw, .fw-form, .fw-form input:not([type]), .fw-form input[type=text], .fw-form input[type=email], .fw-form input[type=url], .fw-form input[type=password], .fw-form textarea { width: 100%; } diff --git a/templates/assets/css/styles.scss b/templates/assets/css/styles.scss index 3ff178c..741b011 100644 --- a/templates/assets/css/styles.scss +++ b/templates/assets/css/styles.scss @@ -92,7 +92,7 @@ form input, form textarea { .fw-form { @extend .fw; - input:not([type]), input[type="text"], input[type="email"], input[type="url"], textarea { + input:not([type]), input[type="text"], input[type="email"], input[type="url"], input[type="password"], textarea { @extend .fw; } } diff --git a/templates/login.gohtml b/templates/login.gohtml new file mode 100644 index 0000000..66912a9 --- /dev/null +++ b/templates/login.gohtml @@ -0,0 +1,22 @@ +{{ define "title" }} + {{ string .Blog.Lang "editor" }} - {{ .Blog.Title }} +{{ end }} + +{{ define "main" }} +
+

{{ string .Blog.Lang "login" }}

+
+ + + + + + + +
+
+{{ end }} + +{{ define "login" }} + {{ template "base" . }} +{{ end }} \ No newline at end of file diff --git a/templates/strings/default.yaml b/templates/strings/default.yaml index 1358710..ebfe02b 100644 --- a/templates/strings/default.yaml +++ b/templates/strings/default.yaml @@ -26,4 +26,7 @@ search: "Search" editor: "Editor" create: "Create" update: "Update" -upload: "Upload" \ No newline at end of file +upload: "Upload" +login: "Login" +username: "Username" +password: "Password" \ No newline at end of file