From e10c3dce3ffe85c25f53b23da07d3372e29189b1 Mon Sep 17 00:00:00 2001 From: Laura Date: Sat, 16 Aug 2025 17:18:48 +0200 Subject: [PATCH] authentication --- README.md | 2 +- auth.go | 125 ++++++++++++++++++++++++++++++++++++++++++++ env.go | 18 +++---- main.go | 18 +++++-- static/css/chat.css | 124 +++++++++++++++++++++++++++++++++++++++++++ static/index.html | 22 ++++++++ static/js/chat.js | 66 ++++++++++++++++++++++- 7 files changed, 357 insertions(+), 18 deletions(-) create mode 100644 auth.go diff --git a/README.md b/README.md index 7ddaf4d..9438805 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode - Structured JSON output - Statistics for messages (provider, ttft, tps and token count) - Import and export of chats as JSON files +- Authentication (optional) ## TODO - Image and file attachments -- Authentication ## Built With diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..2fcfc6a --- /dev/null +++ b/auth.go @@ -0,0 +1,125 @@ +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +type AuthenticationRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (u *EnvUser) Signature(secret string) []byte { + mac := hmac.New(sha256.New, []byte(secret)) + + mac.Write([]byte(u.Password)) + mac.Write([]byte(u.Username)) + + return mac.Sum(nil) +} + +func (e *Environment) Authenticate(username, password string) *EnvUser { + user, ok := e.Authentication.lookup[username] + if !ok { + return nil + } + + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { + return nil + } + + return user +} + +func (e *Environment) SignAuthToken(user *EnvUser) string { + signature := user.Signature(e.Tokens.Secret) + + return user.Username + ":" + hex.EncodeToString(signature) +} + +func (e *Environment) VerifyAuthToken(token string) bool { + index := strings.Index(token, ":") + if index == -1 { + return false + } + + username := token[:index] + + user, ok := e.Authentication.lookup[username] + if !ok { + return false + } + + signature, err := hex.DecodeString(token[index+1:]) + if err != nil { + return false + } + + expected := user.Signature(e.Tokens.Secret) + + return hmac.Equal(signature, expected) +} + +func IsAuthenticated(r *http.Request) bool { + if !env.Authentication.Enabled { + return true + } + + cookie, err := r.Cookie("whiskr_token") + if err != nil { + return false + } + + return env.VerifyAuthToken(cookie.Value) +} + +func Authenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !IsAuthenticated(r) { + RespondJson(w, http.StatusUnauthorized, map[string]any{ + "error": "unauthorized", + }) + + return + } + + next.ServeHTTP(w, r) + }) +} + +func HandleAuthentication(w http.ResponseWriter, r *http.Request) { + var request AuthenticationRequest + + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + RespondJson(w, http.StatusBadRequest, map[string]any{ + "error": "missing username or password", + }) + + return + } + + user := env.Authenticate(request.Username, request.Password) + if user == nil { + RespondJson(w, http.StatusUnauthorized, map[string]any{ + "error": "invalid username or password", + }) + + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "whiskr_token", + Value: env.SignAuthToken(user), + }) + + RespondJson(w, http.StatusOK, map[string]any{ + "authenticated": true, + }) +} diff --git a/env.go b/env.go index 9337df5..bc0f407 100644 --- a/env.go +++ b/env.go @@ -9,7 +9,6 @@ import ( "os" "github.com/goccy/go-yaml" - "golang.org/x/crypto/bcrypt" ) type EnvTokens struct { @@ -29,8 +28,10 @@ type EnvUser struct { } type EnvAuthentication struct { - Enabled bool `json:"enabled"` - Users []EnvUser `json:"users"` + lookup map[string]*EnvUser + + Enabled bool `json:"enabled"` + Users []*EnvUser `json:"users"` } type Environment struct { @@ -94,17 +95,14 @@ func (e *Environment) Init() error { log.Warning("Missing token.exa, web search unavailable") } - return nil -} + // create user lookup map + e.Authentication.lookup = make(map[string]*EnvUser) -func (e *Environment) Authenticate(username, password string) bool { for _, user := range e.Authentication.Users { - if user.Username == username { - return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil - } + e.Authentication.lookup[user.Username] = user } - return false + return nil } func (e *Environment) Store() error { diff --git a/main.go b/main.go index 64eedfd..4091bd4 100644 --- a/main.go +++ b/main.go @@ -34,14 +34,22 @@ func main() { r.Get("/-/data", func(w http.ResponseWriter, r *http.Request) { RespondJson(w, http.StatusOK, map[string]any{ - "version": Version, - "search": env.Tokens.Exa != "", - "models": models, + "authentication": env.Authentication.Enabled, + "authenticated": IsAuthenticated(r), + "search": env.Tokens.Exa != "", + "models": models, + "version": Version, }) }) - r.Get("/-/stats/{id}", HandleStats) - r.Post("/-/chat", HandleChat) + r.Post("/-/auth", HandleAuthentication) + + r.Group(func(gr chi.Router) { + gr.Use(Authenticate) + + gr.Get("/-/stats/{id}", HandleStats) + gr.Post("/-/chat", HandleChat) + }) log.Info("Listening at http://localhost:3443/") http.ListenAndServe(":3443", r) diff --git a/static/css/chat.css b/static/css/chat.css index 71f9e83..908806d 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -605,6 +605,7 @@ select { } body.loading #version, +.modal.loading .content::after, .reasoning .toggle::before, .reasoning .toggle::after, #bottom, @@ -801,6 +802,129 @@ label[for="reasoning-tokens"] { background-image: url(icons/stop.svg); } +.modal .background, +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 20; +} + +.modal:not(.open) { + display: none; +} + +.modal .background { + background: rgba(24, 25, 38, 0.25); + backdrop-filter: blur(10px); +} + +.modal .content, +.modal .body { + display: flex; + flex-direction: column; + gap: 6px; + overflow: hidden; +} + +.modal .content { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + gap: 14px; + background: #24273a; + padding: 18px 20px; + border-radius: 6px; + box-shadow: 0px 0px 15px 5px rgba(0, 0, 0, 0.1); + z-index: 30; +} + +.modal .header { + background: #363a4f; + height: 42px; + margin: -18px -20px; + padding: 10px 20px; + margin-bottom: 0; + font-weight: 500; + font-size: 18px; +} + +.modal.loading .content::after, +.modal.loading .content::before { + content: ""; + position: absolute; + top: 42px; + left: 0; + z-index: 10; +} + +.modal.loading .content::before { + right: 0; + bottom: 0; + backdrop-filter: blur(4px); +} + +.modal.loading .content::after { + top: 85px; + left: 50%; + transform: translateX(-50%); + animation: rotating 1.2s linear infinite; + background-image: url(icons/spinner.svg); +} + +.modal .error { + background: #ed8796; + font-weight: 500; + font-style: italic; + margin-bottom: 6px; + font-size: 14px; + color: #11111b; + padding: 5px 10px; + border-radius: 2px; +} + +.modal:not(.errored) .error { + display: none; +} + +.modal.errored input { + border: 1px solid #ed8796; +} + +.modal .form-group { + display: flex; + gap: 6px; +} + +.modal .form-group label { + width: 80px; +} + +.modal .form-group input { + padding: 4px 6px; +} + +.modal .buttons { + display: flex; + justify-content: end; +} + +#login { + background: #a6da95; + color: #11111b; + padding: 4px 10px; + border-radius: 2px; + font-weight: 500; + transition: 150ms; +} + +#login:hover { + background: #89bb77; +} + @keyframes rotating { from { transform: rotate(0deg); diff --git a/static/index.html b/static/index.html index a0d305d..a086a8d 100644 --- a/static/index.html +++ b/static/index.html @@ -85,6 +85,28 @@ + + diff --git a/static/js/chat.js b/static/js/chat.js index 1ce4af9..5976490 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -17,12 +17,19 @@ $scrolling = document.getElementById("scrolling"), $export = document.getElementById("export"), $import = document.getElementById("import"), - $clear = document.getElementById("clear"); + $clear = document.getElementById("clear"), + $authentication = document.getElementById("authentication"), + $authError = document.getElementById("auth-error"), + $username = document.getElementById("username"), + $password = document.getElementById("password"), + $login = document.getElementById("login"); const messages = [], models = {}, modelList = []; + let authToken; + let autoScrolling = false, searchAvailable = false, jsonMode = false, @@ -879,6 +886,30 @@ ); } + async function login() { + const username = $username.value.trim(), + password = $password.value.trim(); + + if (!username || !password) { + throw new Error("missing username or password"); + } + + const data = await fetch("/-/auth", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: username, + password: password, + }), + }).then((response) => response.json()); + + if (!data?.authenticated) { + throw new Error(data.error || "authentication failed"); + } + } + async function loadData() { const data = await json("/-/data"); @@ -898,6 +929,11 @@ // update search availability searchAvailable = data.search; + // show login modal + if (data.authentication && !data.authenticated) { + $authentication.classList.add("open"); + } + // render models $model.innerHTML = ""; @@ -924,7 +960,7 @@ function clearMessages() { while (messages.length) { - console.log("delete", messages.length) + console.log("delete", messages.length); messages[0].delete(); } } @@ -1169,6 +1205,32 @@ generate(true); }); + $login.addEventListener("click", async () => { + $authentication.classList.remove("errored"); + $authentication.classList.add("loading"); + + try { + await login(); + + $authentication.classList.remove("open"); + } catch(err) { + $authError.textContent =`Error: ${err.message}`; + $authentication.classList.add("errored"); + + $password.value = ""; + } + + $authentication.classList.remove("loading"); + }); + + $username.addEventListener("input", () => { + $authentication.classList.remove("errored"); + }); + + $password.addEventListener("input", () => { + $authentication.classList.remove("errored"); + }); + $message.addEventListener("keydown", (event) => { if (!event.ctrlKey || event.key !== "Enter") { return;