feat: add env-based auth config support
This commit is contained in:
@@ -6,7 +6,6 @@
|
|||||||
"name": "webterm-frontend",
|
"name": "webterm-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ghostty-web": "github:rcarmo/ghostty-web#fcc47d423a7fce1c02c702b6464d0b1ab89175f1",
|
"ghostty-web": "github:rcarmo/ghostty-web#fcc47d423a7fce1c02c702b6464d0b1ab89175f1",
|
||||||
"simple-keyboard": "^3.8.141",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
@@ -16,8 +15,6 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#fcc47d4", {}, "rcarmo-ghostty-web-fcc47d4", "sha512-tq0cFciI32VTyOXDoLHQQDndeA6jhFuZ/3TWYx3VlYDzRhYkWAtTBi6t29isYPzdiKNIWggjkn3Ve/+Qub/wBg=="],
|
"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=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,97 @@ set -e
|
|||||||
|
|
||||||
cd "$(dirname "$0")"
|
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..."
|
echo "Building frontend..."
|
||||||
make build
|
make build
|
||||||
|
|
||||||
@@ -16,6 +107,9 @@ cp bin/webterm "$tmp_target"
|
|||||||
chmod +x "$tmp_target"
|
chmod +x "$tmp_target"
|
||||||
mv "$tmp_target" ~/go/bin/webterm
|
mv "$tmp_target" ~/go/bin/webterm
|
||||||
|
|
||||||
|
echo "Reloading user systemd config..."
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
echo "Restarting service..."
|
echo "Restarting service..."
|
||||||
systemctl --user restart webterm.service
|
systemctl --user restart webterm.service
|
||||||
|
|
||||||
|
|||||||
+124
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ const (
|
|||||||
|
|
||||||
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
|
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
|
||||||
ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE"
|
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"
|
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
|
||||||
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
|
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
|
||||||
DockerHostEnv = "DOCKER_HOST"
|
DockerHostEnv = "DOCKER_HOST"
|
||||||
|
|||||||
Reference in New Issue
Block a user