From ea1ab6b2ce4cba21dcc85c68aaf9a64a6619ced3 Mon Sep 17 00:00:00 2001 From: Isaac Paul Date: Tue, 12 May 2026 12:03:00 -0400 Subject: [PATCH] feat: add env-based auth config support --- bun.lock | 3 -- update.sh | 94 ++++++++++++++++++++++++++++++++ webterm/auth.go | 124 +++++++++++++++++++++++++++++++++++++++++++ webterm/constants.go | 4 ++ 4 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 webterm/auth.go diff --git a/bun.lock b/bun.lock index 7eb5011..cb528b7 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "name": "webterm-frontend", "dependencies": { "ghostty-web": "github:rcarmo/ghostty-web#fcc47d423a7fce1c02c702b6464d0b1ab89175f1", - "simple-keyboard": "^3.8.141", }, "devDependencies": { "typescript": "^5.7.0", @@ -16,8 +15,6 @@ "packages": { "ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#fcc47d4", {}, "rcarmo-ghostty-web-fcc47d4", "sha512-tq0cFciI32VTyOXDoLHQQDndeA6jhFuZ/3TWYx3VlYDzRhYkWAtTBi6t29isYPzdiKNIWggjkn3Ve/+Qub/wBg=="], - "simple-keyboard": ["simple-keyboard@3.8.141", "", {}, "sha512-5kS9RUYk89alam3XsLzOeLHtPMO0km3Lt1d92+Z4P/mhBqomXanFkMDnb6A4EUHA+v3/dbH/IcBT/85l1rnTiA=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], } } diff --git a/update.sh b/update.sh index 178ccfa..c97e89c 100755 --- a/update.sh +++ b/update.sh @@ -3,6 +3,97 @@ set -e cd "$(dirname "$0")" +SERVICE_FILE="$HOME/.config/systemd/user/webterm.service" +DEFAULT_USERNAME="izackp" +DEFAULT_TTL_SECONDS="86400" +UPDATE_AUTH_PASSWORD=0 + +if [ ! -f "$SERVICE_FILE" ]; then + echo "Missing service file: $SERVICE_FILE" >&2 + exit 1 +fi + +while getopts ":p" opt; do + case "$opt" in + p) + UPDATE_AUTH_PASSWORD=1 + ;; + *) + echo "Usage: $0 [-p]" >&2 + exit 1 + ;; + esac +done +shift $((OPTIND - 1)) + +if [ "$UPDATE_AUTH_PASSWORD" -eq 1 ]; then + echo "Enter password for webterm login." + read -rsp "Password for $DEFAULT_USERNAME: " WEBTERM_PASSWORD + echo + if [ -z "$WEBTERM_PASSWORD" ]; then + echo "Password cannot be empty." >&2 + exit 1 + fi +fi + +CURRENT_SECRET="$(sed -n 's/^Environment=WEBTERM_AUTH_COOKIE_SECRET=//p' "$SERVICE_FILE" | tail -n 1)" +if [ -z "$CURRENT_SECRET" ]; then + CURRENT_SECRET="$(openssl rand -hex 32)" +fi + +if [ "$UPDATE_AUTH_PASSWORD" -eq 1 ]; then + TMP_SERVICE="$(mktemp)" + awk ' + BEGIN { + skip["Environment=WEBTERM_AUTH_USERNAME"]=1 + skip["Environment=WEBTERM_AUTH_PASSWORD"]=1 + skip["Environment=WEBTERM_AUTH_COOKIE_SECRET"]=1 + skip["Environment=WEBTERM_AUTH_SESSION_TTL_SECONDS"]=1 + } + { + for (prefix in skip) { + if (index($0, prefix) == 1) next + } + print + } + ' "$SERVICE_FILE" > "$TMP_SERVICE" + + python3 - "$TMP_SERVICE" "$WEBTERM_PASSWORD" "$CURRENT_SECRET" "$DEFAULT_TTL_SECONDS" <<'PYEOF' +import sys +from pathlib import Path +import shlex + +path = Path(sys.argv[1]) +username = "izackp" +password = sys.argv[2] +secret = sys.argv[3] +ttl = sys.argv[4] + +lines = path.read_text().splitlines() +insert = [ + f"Environment=WEBTERM_AUTH_USERNAME={shlex.quote(username)}", + f"Environment=WEBTERM_AUTH_PASSWORD={shlex.quote(password)}", + f"Environment=WEBTERM_AUTH_COOKIE_SECRET={shlex.quote(secret)}", + f"Environment=WEBTERM_AUTH_SESSION_TTL_SECONDS={shlex.quote(ttl)}", +] + +out = [] +inserted = False +for line in lines: + out.append(line) + if line.strip() == "[Service]": + out.extend(insert) + inserted = True + +if not inserted: + raise SystemExit("Could not find [Service] section in webterm.service") + +path.write_text("\n".join(out) + "\n") +PYEOF + + mv "$TMP_SERVICE" "$SERVICE_FILE" +fi + echo "Building frontend..." make build @@ -16,6 +107,9 @@ cp bin/webterm "$tmp_target" chmod +x "$tmp_target" mv "$tmp_target" ~/go/bin/webterm +echo "Reloading user systemd config..." +systemctl --user daemon-reload + echo "Restarting service..." systemctl --user restart webterm.service diff --git a/webterm/auth.go b/webterm/auth.go new file mode 100644 index 0000000..868e242 --- /dev/null +++ b/webterm/auth.go @@ -0,0 +1,124 @@ +package webterm + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +const ( + authCookieName = "webterm_session" + defaultAuthUsername = "izackp" + defaultAuthSessionTTL = 24 * time.Hour +) + +type authConfig struct { + username string + password string + secret []byte + sessionTTL time.Duration +} + +func newAuthConfig() *authConfig { + username := strings.TrimSpace(os.Getenv(AuthUsernameEnv)) + if username == "" { + username = defaultAuthUsername + } + + password := strings.TrimSpace(os.Getenv(AuthPasswordEnv)) + if password == "" { + return nil + } + + sessionTTL := defaultAuthSessionTTL + if raw := strings.TrimSpace(os.Getenv(AuthSessionTTLSecondsEnv)); raw != "" { + if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 { + sessionTTL = time.Duration(seconds) * time.Second + } + } + + secretRaw := strings.TrimSpace(os.Getenv(AuthCookieSecretEnv)) + if secretRaw == "" { + sum := sha256.Sum256([]byte("webterm:" + password)) + secretRaw = hex.EncodeToString(sum[:]) + } + + return &authConfig{ + username: username, + password: password, + secret: []byte(secretRaw), + sessionTTL: sessionTTL, + } +} + +func (a *authConfig) cookieValue(now time.Time) string { + expiresAt := now.Add(a.sessionTTL).Unix() + payload := fmt.Sprintf("v1:%d", expiresAt) + mac := hmac.New(sha256.New, a.secret) + _, _ = mac.Write([]byte(payload)) + signature := hex.EncodeToString(mac.Sum(nil)) + return base64.RawURLEncoding.EncodeToString([]byte(payload + ":" + signature)) +} + +func (a *authConfig) validCookieValue(value string, now time.Time) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return false + } + + parts := strings.Split(string(decoded), ":") + if len(parts) != 3 || parts[0] != "v1" { + return false + } + + expiresAt, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || now.Unix() > expiresAt { + return false + } + + payload := parts[0] + ":" + parts[1] + mac := hmac.New(sha256.New, a.secret) + _, _ = mac.Write([]byte(payload)) + expected := hex.EncodeToString(mac.Sum(nil)) + return subtle.ConstantTimeCompare([]byte(parts[2]), []byte(expected)) == 1 +} + +func (a *authConfig) passwordMatches(password string) bool { + return subtle.ConstantTimeCompare([]byte(password), []byte(a.password)) == 1 +} + +func (a *authConfig) usernameMatches(username string) bool { + return subtle.ConstantTimeCompare([]byte(username), []byte(a.username)) == 1 +} + +func (a *authConfig) sessionCookie(value string, r *http.Request) *http.Cookie { + return &http.Cookie{ + Name: authCookieName, + Value: value, + Path: "/", + HttpOnly: true, + Secure: isSecureRequest(r), + SameSite: http.SameSiteLaxMode, + MaxAge: int(a.sessionTTL / time.Second), + } +} + +func (a *authConfig) clearCookie(r *http.Request) *http.Cookie { + return &http.Cookie{ + Name: authCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: isSecureRequest(r), + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + } +} diff --git a/webterm/constants.go b/webterm/constants.go index 2a38fdc..74a460e 100644 --- a/webterm/constants.go +++ b/webterm/constants.go @@ -16,6 +16,10 @@ const ( ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW" ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE" + AuthUsernameEnv = "WEBTERM_AUTH_USERNAME" + AuthPasswordEnv = "WEBTERM_AUTH_PASSWORD" + AuthCookieSecretEnv = "WEBTERM_AUTH_COOKIE_SECRET" + AuthSessionTTLSecondsEnv = "WEBTERM_AUTH_SESSION_TTL_SECONDS" DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME" DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND" DockerHostEnv = "DOCKER_HOST"