From 7afa2d48c4836d0a358df4f6e5332ee5058c864f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 11 Oct 2024 22:27:29 +0300 Subject: [PATCH] server: add authentication --- config.go | 112 +++++++++++++++++++++++++++++ go.mod | 5 +- go.sum | 6 +- gomuks.go | 51 ++----------- main.go | 10 ++- server.go | 181 +++++++++++++++++++++++++++++++++++++++++++++++ web/src/main.tsx | 18 +++-- websocket.go | 5 +- 8 files changed, 328 insertions(+), 60 deletions(-) create mode 100644 config.go create mode 100644 server.go diff --git a/config.go b/config.go new file mode 100644 index 0000000..e2c5a95 --- /dev/null +++ b/config.go @@ -0,0 +1,112 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/rs/zerolog" + "go.mau.fi/util/ptr" + "go.mau.fi/util/random" + "go.mau.fi/zeroconfig" + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v3" +) + +type Config struct { + Web WebConfig `yaml:"web"` + Logging zeroconfig.Config `yaml:"logging"` +} + +type WebConfig struct { + ListenAddress string `yaml:"listen_address"` + Username string `yaml:"username"` + PasswordHash string `yaml:"password_hash"` + TokenKey string `yaml:"token_key"` +} + +var defaultConfig = Config{ + Web: WebConfig{ + ListenAddress: "localhost:29325", + }, + Logging: zeroconfig.Config{ + MinLevel: ptr.Ptr(zerolog.TraceLevel), + Writers: []zeroconfig.WriterConfig{{ + Type: zeroconfig.WriterTypeStdout, + Format: zeroconfig.LogFormatPrettyColored, + }}, + }, +} + +func (gmx *Gomuks) LoadConfig() error { + file, err := os.Open(filepath.Join(gmx.ConfigDir, "config.yaml")) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + gmx.Config = defaultConfig + changed := false + if file != nil { + err = yaml.NewDecoder(file).Decode(&gmx.Config) + if err != nil { + return err + } + } else { + changed = true + } + if gmx.Config.Web.TokenKey == "" { + gmx.Config.Web.TokenKey = random.String(64) + changed = true + } + if gmx.Config.Web.Username == "" || gmx.Config.Web.PasswordHash == "" { + fmt.Println("Please create a username and password for authenticating the web app") + fmt.Print("Username: ") + _, err = fmt.Scanln(&gmx.Config.Web.Username) + if err != nil { + return fmt.Errorf("failed to read username: %w", err) + } + fmt.Print("Password: ") + var passwd string + _, err = fmt.Scanln(&passwd) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + hash, err := bcrypt.GenerateFromPassword([]byte(passwd), 12) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + gmx.Config.Web.PasswordHash = string(hash) + changed = true + } + if changed { + err = gmx.SaveConfig() + if err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + } + return nil +} + +func (gmx *Gomuks) SaveConfig() error { + file, err := os.OpenFile(filepath.Join(gmx.ConfigDir, "config.yaml"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + return yaml.NewEncoder(file).Encode(&gmx.Config) +} diff --git a/go.mod b/go.mod index 561cf57..c7db0d7 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,10 @@ require ( github.com/coder/websocket v1.8.12 github.com/mattn/go-sqlite3 v1.14.23 github.com/rs/zerolog v1.33.0 - go.mau.fi/util v0.8.1-0.20241007163534-d608488d5cff + go.mau.fi/util v0.8.1-0.20241011175353-d86e4c6b8fa7 go.mau.fi/zeroconfig v0.1.3 + golang.org/x/crypto v0.27.0 + gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mauflag v1.0.0 maunium.net/go/mautrix v0.21.1-0.20241010172818-50f4a2eec193 ) @@ -25,7 +27,6 @@ require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.5 // indirect - golang.org/x/crypto v0.27.0 // indirect golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect golang.org/x/net v0.29.0 // indirect golang.org/x/sys v0.25.0 // indirect diff --git a/go.sum b/go.sum index 8a06294..e7d846e 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.mau.fi/util v0.8.1-0.20241007163534-d608488d5cff h1:oQJHc5pdU9Xc6mhvfxSdWXoTreFYvlkM7g65MJWhR40= -go.mau.fi/util v0.8.1-0.20241007163534-d608488d5cff/go.mod h1:L9qnqEkhe4KpuYmILrdttKTXL79MwGLyJ4EOskWxO3I= +go.mau.fi/util v0.8.1-0.20241011175353-d86e4c6b8fa7 h1:BmQx/qeiR84yGDCir8QL5BtW8XLV7NNDjb44SWnPOOw= +go.mau.fi/util v0.8.1-0.20241011175353-d86e4c6b8fa7/go.mod h1:L9qnqEkhe4KpuYmILrdttKTXL79MwGLyJ4EOskWxO3I= go.mau.fi/zeroconfig v0.1.3 h1:As9wYDKmktjmNZW5i1vn8zvJlmGKHeVxHVIBMXsm4kM= go.mau.fi/zeroconfig v0.1.3/go.mod h1:NcSJkf180JT+1IId76PcMuLTNa1CzsFFZ0nBygIQM70= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= @@ -52,6 +52,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/gomuks.go b/gomuks.go index 7ca4a7e..4d58cf7 100644 --- a/gomuks.go +++ b/gomuks.go @@ -18,9 +18,7 @@ package main import ( "context" - "errors" "fmt" - "io/fs" "maps" "net/http" "os" @@ -33,17 +31,10 @@ import ( "github.com/coder/websocket" "github.com/rs/zerolog" - "github.com/rs/zerolog/hlog" "go.mau.fi/util/dbutil" "go.mau.fi/util/exerrors" - "go.mau.fi/util/exhttp" "go.mau.fi/util/exzerolog" - "go.mau.fi/util/ptr" - "go.mau.fi/util/requestlog" - "go.mau.fi/zeroconfig" "maunium.net/go/mautrix/hicli" - - "go.mau.fi/gomuks/web" ) type Gomuks struct { @@ -57,6 +48,8 @@ type Gomuks struct { TempDir string LogDir string + Config Config + stopOnce sync.Once stopChan chan struct{} @@ -74,7 +67,7 @@ func NewGomuks() *Gomuks { } } -func (gmx *Gomuks) LoadConfig() { +func (gmx *Gomuks) InitDirectories() { // We need 4 directories: config, data, cache, logs // // 1. If GOMUKS_ROOT is set, all directories are created under that. @@ -146,46 +139,10 @@ func (gmx *Gomuks) LoadConfig() { } func (gmx *Gomuks) SetupLog() { - gmx.Log = exerrors.Must((&zeroconfig.Config{ - MinLevel: ptr.Ptr(zerolog.TraceLevel), - Writers: []zeroconfig.WriterConfig{{ - Type: zeroconfig.WriterTypeStdout, - Format: zeroconfig.LogFormatPrettyColored, - }}, - }).Compile()) + gmx.Log = exerrors.Must(gmx.Config.Logging.Compile()) exzerolog.SetupDefaults(gmx.Log) } -func (gmx *Gomuks) StartServer(addr string) { - api := http.NewServeMux() - api.HandleFunc("GET /websocket", gmx.HandleWebsocket) - api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia) - apiHandler := exhttp.ApplyMiddleware( - http.StripPrefix("/_gomuks", api), - hlog.NewHandler(*gmx.Log), - hlog.RequestIDHandler("request_id", "Request-ID"), - requestlog.AccessLogger(false), - ) - router := http.NewServeMux() - router.Handle("/_gomuks/", apiHandler) - if frontend, err := fs.Sub(web.Frontend, "dist"); err != nil { - gmx.Log.Warn().Msg("Frontend not found") - } else { - router.Handle("/", http.FileServerFS(frontend)) - } - gmx.Server = &http.Server{ - Addr: addr, - Handler: router, - } - go func() { - err := gmx.Server.ListenAndServe() - if err != nil && !errors.Is(err, http.ErrServerClosed) { - panic(err) - } - }() - gmx.Log.Info().Str("address", addr).Msg("Server started") -} - func (gmx *Gomuks) StartClient() { rawDB, err := dbutil.NewFromConfig("gomuks", dbutil.Config{ PoolConfig: dbutil.PoolConfig{ diff --git a/main.go b/main.go index 3f88180..9706301 100644 --- a/main.go +++ b/main.go @@ -48,7 +48,6 @@ var ( var wantHelp, _ = flag.MakeHelpFlag() var version = flag.MakeFull("v", "version", "View gomuks version and quit.", "false").Bool() -var listenAddress = flag.MakeFull("l", "listen", "Address to listen on.", "localhost:29325").String() func main() { hicli.InitialDeviceDisplayName = "gomuks web" @@ -72,14 +71,19 @@ func main() { } gmx := NewGomuks() - gmx.LoadConfig() + gmx.InitDirectories() + err = gmx.LoadConfig() + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "Failed to load config:", err) + os.Exit(9) + } gmx.SetupLog() gmx.Log.Info(). Str("version", Version). Str("go_version", runtime.Version()). Time("built_at", ParsedBuildTime). Msg("Initializing gomuks") - gmx.StartServer(*listenAddress) + gmx.StartServer() gmx.StartClient() gmx.Log.Info().Msg("Initialization complete") gmx.WaitForInterrupt() diff --git a/server.go b/server.go new file mode 100644 index 0000000..d9f6c1a --- /dev/null +++ b/server.go @@ -0,0 +1,181 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "io/fs" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/hlog" + "go.mau.fi/util/exerrors" + "go.mau.fi/util/exhttp" + "go.mau.fi/util/jsontime" + "go.mau.fi/util/requestlog" + "golang.org/x/crypto/bcrypt" + "maunium.net/go/mautrix" + + "go.mau.fi/gomuks/web" +) + +func (gmx *Gomuks) StartServer() { + api := http.NewServeMux() + api.HandleFunc("GET /websocket", gmx.HandleWebsocket) + api.HandleFunc("POST /auth", gmx.Authenticate) + api.HandleFunc("GET /media/{server}/{media_id}", gmx.DownloadMedia) + apiHandler := exhttp.ApplyMiddleware( + api, + hlog.NewHandler(*gmx.Log), + hlog.RequestIDHandler("request_id", "Request-ID"), + requestlog.AccessLogger(false), + exhttp.StripPrefix("/_gomuks"), + gmx.AuthMiddleware, + ) + router := http.NewServeMux() + router.Handle("/_gomuks/", apiHandler) + if frontend, err := fs.Sub(web.Frontend, "dist"); err != nil { + gmx.Log.Warn().Msg("Frontend not found") + } else { + router.Handle("/", http.FileServerFS(frontend)) + } + gmx.Server = &http.Server{ + Addr: gmx.Config.Web.ListenAddress, + Handler: router, + } + go func() { + err := gmx.Server.ListenAndServe() + if err != nil && !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + }() + gmx.Log.Info().Str("address", gmx.Config.Web.ListenAddress).Msg("Server started") +} + +var ( + ErrInvalidHeader = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_HEADER", StatusCode: http.StatusBadRequest} + ErrMissingCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.MISSING_COOKIE", Err: "Missing gomuks_auth cookie", StatusCode: http.StatusUnauthorized} + ErrInvalidCookie = mautrix.RespError{ErrCode: "FI.MAU.GOMUKS.INVALID_COOKIE", Err: "Invalid gomuks_auth cookie", StatusCode: http.StatusUnauthorized} +) + +type tokenData struct { + Username string `json:"username"` + Expiry jsontime.Unix `json:"expiry"` +} + +func (gmx *Gomuks) validateAuth(token string) bool { + if len(token) > 100 { + return false + } + parts := strings.Split(token, ".") + if len(parts) != 2 { + return false + } + rawJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false + } + checksum, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey)) + hasher.Write(rawJSON) + if !hmac.Equal(hasher.Sum(nil), checksum) { + return false + } + + var td tokenData + err = json.Unmarshal(rawJSON, &td) + return err == nil && td.Username == gmx.Config.Web.Username && td.Expiry.After(time.Now()) +} + +func (gmx *Gomuks) generateToken() (string, time.Time) { + expiry := time.Now().Add(7 * 24 * time.Hour) + data := exerrors.Must(json.Marshal(tokenData{ + Username: gmx.Config.Web.Username, + Expiry: jsontime.U(expiry), + })) + hasher := hmac.New(sha256.New, []byte(gmx.Config.Web.TokenKey)) + hasher.Write(data) + checksum := hasher.Sum(nil) + return base64.RawURLEncoding.EncodeToString(data) + "." + base64.RawURLEncoding.EncodeToString(checksum), expiry +} + +func (gmx *Gomuks) writeTokenCookie(w http.ResponseWriter) { + token, expiry := gmx.generateToken() + http.SetCookie(w, &http.Cookie{ + Name: "gomuks_auth", + Value: token, + Expires: expiry, + HttpOnly: true, + }) +} + +func (gmx *Gomuks) Authenticate(w http.ResponseWriter, r *http.Request) { + authCookie, err := r.Cookie("gomuks_auth") + if err == nil && gmx.validateAuth(authCookie.Value) { + gmx.writeTokenCookie(w) + w.WriteHeader(http.StatusOK) + } else if username, password, ok := r.BasicAuth(); !ok { + w.Header().Set("WWW-Authenticate", `Basic realm="gomuks web" charset="UTF-8"`) + w.WriteHeader(http.StatusUnauthorized) + } else { + usernameHash := sha256.Sum256([]byte(username)) + expectedUsernameHash := sha256.Sum256([]byte(gmx.Config.Web.Username)) + usernameCorrect := hmac.Equal(usernameHash[:], expectedUsernameHash[:]) + passwordCorrect := bcrypt.CompareHashAndPassword([]byte(gmx.Config.Web.PasswordHash), []byte(password)) == nil + if usernameCorrect && passwordCorrect { + gmx.writeTokenCookie(w) + w.WriteHeader(http.StatusCreated) + } else { + w.WriteHeader(http.StatusForbidden) + } + } +} + +func isUserFetch(header http.Header) bool { + return header.Get("Sec-Fetch-Site") == "none" && + header.Get("Sec-Fetch-Mode") == "navigate" && + header.Get("Sec-Fetch-Dest") == "document" && + header.Get("Sec-Fetch-User") == "?1" +} + +func (gmx *Gomuks) AuthMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Sec-Fetch-Site") != "same-origin" && !isUserFetch(r.Header) { + ErrInvalidHeader.WithMessage("Invalid Sec-Fetch-Site header").Write(w) + return + } + if r.URL.Path != "/auth" { + authCookie, err := r.Cookie("gomuks_auth") + if err != nil { + ErrMissingCookie.Write(w) + return + } else if !gmx.validateAuth(authCookie.Value) { + ErrInvalidCookie.Write(w) + return + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 173a9d9..2fbee8a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -18,8 +18,16 @@ import { createRoot } from "react-dom/client" import App from "./App.tsx" import "./index.css" -createRoot(document.getElementById("root")!).render( - - - , -) +fetch("/_gomuks/auth", { method: "POST" }).then(resp => { + if (resp.ok) { + createRoot(document.getElementById("root")!).render( + + + , + ) + } else { + window.alert("Authentication failed: " + resp.statusText) + } +}, err => { + window.alert("Authentication failed: " + err) +}) diff --git a/websocket.go b/websocket.go index bc5037b..f768f57 100644 --- a/websocket.go +++ b/websocket.go @@ -27,7 +27,6 @@ import ( "github.com/coder/websocket" "github.com/rs/zerolog" - "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/hicli" "maunium.net/go/mautrix/hicli/database" @@ -49,6 +48,10 @@ func writeCmd(ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommand) const StatusEventsStuck = 4001 func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Sec-Fetch-Mode") != "websocket" { + ErrInvalidHeader.WithMessage("Invalid Sec-Fetch-Dest header").Write(w) + return + } var conn *websocket.Conn log := zerolog.Ctx(r.Context()) recoverPanic := func(context string) bool {