diff --git a/server/go/README.md b/server/go/README.md index 55beeff..6e0f579 100644 --- a/server/go/README.md +++ b/server/go/README.md @@ -170,6 +170,7 @@ VSCode F5 调试时由 `sync-web-assets` preLaunchTask 自动同步。 | `YAMA_USERS_FILE` | Path to the JSON file that persists non-admin web users (allowed_groups, password hash, salt). Default is `users.json` in the working directory. | `users.json` | | `YAMA_WEB_ALLOWED_ORIGINS` | Comma-separated WebSocket Origin allowlist for cross-origin upgrades. Empty (default) → only same-origin upgrades are accepted, which is correct when the web UI and `/ws` share a host. Add an entry per trusted PWA / dev origin. | `https://yama.example.com,https://yama-mobile.example.com` | | `YAMA_WEB_TRUST_PROXY` | Set to `1` only when running behind a reverse proxy you control (caddy / nginx / cloudflare). Switches client-IP extraction to use the last entry of `X-Forwarded-For` instead of `RemoteAddr`, so per-IP login rate limit sees the real client. Direct-exposure deployments MUST leave this unset — otherwise attackers can spoof the header to evade rate limits. | `1` | +| `YAMA_SSO_JWT_SECRET` | **[SSO]** Shared HS256 secret for bridging the parent portal's `auth_token` JWT into a YAMA web session via `/sso`. Must equal the upstream portal's signing key (FMS: `SERVER_PRIVATE_KEY`). **Only `admin` portal users are auto-logged-in**; other roles still need YAMA's own web login. Unset → SSO disabled, `/sso` returns 401. | `` | ```bash # Linux/macOS @@ -598,6 +599,52 @@ nohup ./server_linux_amd64 -p 6543 --http-port=8080 --no-console & - 受管设备的 `client.exe` 由 Windows 主控端生成(已绑定服务端地址和公钥),下级运营商把它分发给终端用户。 client 通过 6543 端口连接到本服务端;用户通过 8080 端口登录 Web 管理页面。 +### 验证与排障 + +下级服务是否真的拿到付费授权,两侧各看一行日志即可定位: + +| 现象 | 授权中心日志(`logs/server.log`) | 下级启动日志 | 结论 | +| ---- | -------------------------------- | ------------ | ---- | +| 付费生效 | `License Server: signed for sub=<客户名> ... active=N/M ttl=...` | `Signer mode: REMOTE (paid customer, ...)` | ✅ 正常 | +| 匿名试用 | `License Server: signed for sub=trial:<下级出口IP> ...` | `Signer mode: TRIAL (...)` | ⚠️ 下级没读到 `YAMA_LICENSE_TOKEN` | +| token 无效 | nginx access.log 里 `/license/sign` 回 `401` | `Signer mode: REMOTE` 但设备连不上 | ⚠️ token 过期 / 密钥不匹配 / 被篡改 | + +**关键区分**:`sub=trial:` 表示下级请求**根本没带 `Authorization` 头**(token 为空,等于没读到), +而不是 token 错 —— token 错会返回 401。两者根因不同,别误判成「token 配错了」。 + +#### systemd 改 env 文件后不生效(最常见坑) + +`EnvironmentFile=` 只在服务**启动那一刻**读一次,改文件不会热更新,必须重启服务。典型表现就是 +改了 `YAMA_LICENSE_TOKEN` 后日志仍显示 `Signer mode: TRIAL`。 + +```bash +# 1. 确认运行中的进程到底有没有读到该变量(这才是真相) +PID=$(systemctl show <服务名> -p MainPID --value) +tr '\0' '\n' < /proc/$PID/environ | grep -i YAMA_LICENSE_TOKEN || echo "进程里没有该变量" + +# 2. 重启后再验一次 + 看启动日志 +systemctl daemon-reload && systemctl restart <服务名> +journalctl -u <服务名> --no-pager -n 40 | grep -iE "signer mode" +``` + +下级日志出现 `Signer mode: REMOTE` 说明 token 已读入;授权中心日志出现 `sub=<客户名>` 即全链路闭环。 + +### SSO(单点登录,可选) + +Web UI 挂在已有域名(如 FMS 网关)的 `/remoter` 子路径下时,可让**已登录门户的管理员免二次登录**直接进远程桌面管理页。设 `YAMA_SSO_JWT_SECRET`(= 上游门户的 HS256 签名密钥,FMS 即 `SERVER_PRIVATE_KEY`)后启用: + +```bash +export YAMA_SSO_JWT_SECRET="与上游门户一致的共享密钥" +``` + +行为: + +- `/sso` 校验浏览器携带的 `auth_token` cookie(或 `Authorization: Bearer`),通过后铸一枚 YAMA 自己的会话 token 返回给前端。 +- **只有 `admin` 角色的门户用户会被自动登录**;其他角色(viewer / 未知)即使已登录门户,`/sso` 仍返回 401,前端回退到 YAMA 自带的 Web 登录表单,必须再输一遍 YAMA 的登录密码。 +- 未配置 `YAMA_SSO_JWT_SECRET` 时 `/sso` 恒 401,行为等同纯本地登录。 + +注意:SSO 只桥接「识别门户身份」,YAMA 本地登录(账号 + 密码)始终保留作为兜底;自动登录后的角色权限仍由 YAMA 自己的账号体系决定。 + ## License MIT License diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index 4f31d8f..531ac66 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -935,12 +935,26 @@ func main() { if basePath != "" { log.Info("Serving web UI under base path %q (subpath deployment)", basePath) } + // SSO: an optional detector bridges an already-authenticated portal + // session into a YAMA web session via /sso — but only for portal + // administrators. Any other portal role (viewer, unknown) still has to + // authenticate against YAMA's own login form. The built-in JWT detector is + // enabled by YAMA_SSO_JWT_SECRET (the shared HS256 secret); a different + // portal can plug in its own web.SSODetector by assigning webCfg.SSO. + var ssoDetector web.SSODetector + if secret := os.Getenv("YAMA_SSO_JWT_SECRET"); secret != "" { + ssoDetector = web.NewJWTDetector(web.JWTDetectorConfig{Secret: secret}) + log.Info("SSO enabled: bridging portal admins into web sessions via /sso") + } else { + log.Info("SSO disabled (YAMA_SSO_JWT_SECRET not set) — web login only") + } webCfg := web.Config{ AllowedOrigins: allowedOrigins, LoginIPLimit: wsauth.NewRateLimiter(10, time.Minute), LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute), TrustForwardedFor: trustProxy, BasePath: basePath, + SSO: ssoDetector, } // Start HTTP server for web UI. Hub gives it read-only access to the diff --git a/server/go/deploy/yama-server.env.example b/server/go/deploy/yama-server.env.example index 3d77cd9..f2c43b1 100644 --- a/server/go/deploy/yama-server.env.example +++ b/server/go/deploy/yama-server.env.example @@ -33,6 +33,15 @@ YAMA_WEB_TRUST_PROXY=1 # WebSocket Origin 白名单(逗号分隔)。空 = 仅允许同源 #YAMA_WEB_ALLOWED_ORIGINS=https://你的域名 +# ---------- SSO(单点登录,可选)---------- + +# 共享的 HS256 签名密钥。设置后启用 SSO:YAMA 校验父域名(如 FMS 网关)下发的 +# auth_token JWT,通过后为门户管理员铸一枚 YAMA 会话、免二次登录。 +# 取值必须与上游门户的签名密钥一致(FMS 即 SERVER_PRIVATE_KEY)。 +# ⚠️ 仅 admin 角色的门户用户会被自动登录;其他角色(viewer / 未知)即使已登录 +# 门户,也必须再输一遍 YAMA 自己的 Web 登录密码(/sso 返回 401 → 回退登录页)。 +#YAMA_SSO_JWT_SECRET= + # ---------- 授权 / 许可(signer)---------- # 优先级:YAMA_SIGN_PASSWORD > YAMA_LICENSE_DISABLED=1 > YAMA_LICENSE_TOKEN > 匿名试用 @@ -48,6 +57,9 @@ YAMA_WEB_TRUST_PROXY=1 # 客户 JWT(付费客户解锁设备配额) #YAMA_LICENSE_TOKEN= +# ⚠️ EnvironmentFile 只在服务启动时读一次:改本文件后必须 systemctl restart <服务>, +# 否则改动不生效(现象:启动日志仍是 "Signer mode: TRIAL" 而非 REMOTE) + # 缓存 TTL(小时,RemoteSigner 离线缓存,默认 24) #YAMA_LICENSE_OFFLINE_HRS=24 diff --git a/server/go/web/server.go b/server/go/web/server.go index 61241bb..526bbba 100644 --- a/server/go/web/server.go +++ b/server/go/web/server.go @@ -32,6 +32,7 @@ type Server struct { loginUserLimit *wsauth.RateLimiter trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only) basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root + sso SSODetector // nil = SSO disabled } // Config tunes the server's exposed-on-public-HTTPS hardening knobs. @@ -60,6 +61,10 @@ type Config struct { // https://example.com/remoter). Must be empty or a leading-slash, // no-trailing-slash path; "" (default) serves from the domain root. BasePath string + // SSO is an optional detector that maps an already-authenticated portal + // session (cookie/header) into a YAMA web session via /sso. nil disables + // SSO and the front-end falls back to the built-in login form. + SSO SSODetector } // New creates an HTTP server bound to the given port. port=0 disables the server. @@ -77,6 +82,7 @@ func (s *Server) WithConfig(cfg Config) *Server { s.loginUserLimit = cfg.LoginUserLimit s.trustForwardedFor = cfg.TrustForwardedFor s.basePath = cfg.BasePath + s.sso = cfg.SSO return s } @@ -98,6 +104,7 @@ func (s *Server) Start() error { mux.HandleFunc("/health", s.handleHealth) mux.HandleFunc("/manifest.json", s.handleManifest) mux.HandleFunc("/api/devices", s.requireBearer(s.handleDevices)) + mux.HandleFunc("/sso", s.handleSSO) mux.HandleFunc("/ws", s.ws.serve) mux.HandleFunc("/static/xterm.js", staticHandler(xtermJS, "application/javascript; charset=utf-8")) mux.HandleFunc("/static/xterm.css", staticHandler(xtermCSS, "text/css; charset=utf-8")) diff --git a/server/go/web/sso.go b/server/go/web/sso.go new file mode 100644 index 0000000..a178fac --- /dev/null +++ b/server/go/web/sso.go @@ -0,0 +1,63 @@ +package web + +import ( + "encoding/json" + "net/http" +) + +// SSODetector maps an incoming HTTP request from an already-authenticated +// portal session into a YAMA web identity (username + role). Implementations +// are portal-specific: each one knows how to recognize its portal's session +// credential (a cookie, a header, a JWT, ...) and translate the caller to +// YAMA's two roles, "admin" and "viewer". +// +// Detect returns ok=false when the request carries no usable credential, or +// the credential is invalid/expired; the caller then falls back to the +// built-in login form. username is used only for audit/logging. Whether a +// detected identity is actually bridged into a session is handleSSO's policy: +// currently only "admin" is bridged, everyone else falls back to the login form. +type SSODetector interface { + Detect(r *http.Request) (username, role string, ok bool) +} + +// handleSSO bridges a detected portal identity into a YAMA session token. +// Only administrators are bridged: a valid portal session for any other role +// (viewer, unknown) still returns 401 and the front-end falls back to the +// built-in login form, so a low-privileged portal account cannot walk straight +// into the remote-desktop console. When no detector is configured, or the +// request is not recognized, it also returns 401. +func (s *Server) handleSSO(w http.ResponseWriter, r *http.Request) { + if s.sso == nil { + s.unauthorized(w) + return + } + username, role, ok := s.sso.Detect(r) + if !ok { + s.unauthorized(w) + return + } + // Only the "admin" role may skip the login form via SSO. Every other role + // (viewer, unknown) must authenticate against YAMA's own login, even with + // a valid portal session. + if role != "admin" { + s.unauthorized(w) + return + } + + token, err := s.auth.IssueToken(username, role) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + return + } + + s.log.Info("sso: user=%s role=%s", username, role) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]string{ + "token": token, + "role": role, + "username": username, + }) +} diff --git a/server/go/web/sso_jwt.go b/server/go/web/sso_jwt.go new file mode 100644 index 0000000..17c19fc --- /dev/null +++ b/server/go/web/sso_jwt.go @@ -0,0 +1,127 @@ +package web + +import ( + "errors" + "net/http" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTDetector is an SSODetector for portals that hand the browser a signed +// JWT as an HttpOnly session cookie (or an Authorization: Bearer header). It +// verifies the token with a shared HMAC secret and maps the identity claims +// to a YAMA role. It is portal-agnostic: the secret, cookie name, claim keys +// and the role value that means "admin" are all supplied at construction, so +// the same detector serves any portal using this pattern. +type JWTDetector struct { + secret []byte + cookieName string + usernameClaim string + roleClaim string + adminRole string +} + +// JWTDetectorConfig configures a JWTDetector. Secret is the shared HMAC key +// (required). The remaining fields have defaults for a typical portal: +// CookieName "auth_token", UsernameClaim "username", RoleClaim "rolename", +// AdminRole "admin". +type JWTDetectorConfig struct { + Secret string + CookieName string + UsernameClaim string + RoleClaim string + AdminRole string +} + +// NewJWTDetector builds a JWTDetector, applying defaults for any unset +// optional field. +func NewJWTDetector(cfg JWTDetectorConfig) *JWTDetector { + if cfg.CookieName == "" { + cfg.CookieName = "auth_token" + } + if cfg.UsernameClaim == "" { + cfg.UsernameClaim = "username" + } + if cfg.RoleClaim == "" { + cfg.RoleClaim = "rolename" + } + if cfg.AdminRole == "" { + cfg.AdminRole = "admin" + } + return &JWTDetector{ + secret: []byte(cfg.Secret), + cookieName: cfg.CookieName, + usernameClaim: cfg.UsernameClaim, + roleClaim: cfg.RoleClaim, + adminRole: cfg.AdminRole, + } +} + +// Detect reads the JWT from the configured cookie (falling back to a Bearer +// header), verifies its signature and expiry, and maps the identity to a YAMA +// role. Only HMAC algorithms are accepted, rejecting algorithm-confusion +// attempts (alg=none / asymmetric algorithms). +func (d *JWTDetector) Detect(r *http.Request) (username, role string, ok bool) { + tokenString := d.extract(r) + if tokenString == "" { + return "", "", false + } + + claims, ok := d.parse(tokenString) + if !ok { + return "", "", false + } + + username, _ = claims[d.usernameClaim].(string) + if username == "" { + return "", "", false + } + + rolename, _ := claims[d.roleClaim].(string) + if rolename == d.adminRole { + role = "admin" + } else { + role = "viewer" + } + return username, role, true +} + +// extract returns the raw JWT from the configured cookie, or from a Bearer +// header as a fallback. +func (d *JWTDetector) extract(r *http.Request) string { + if d.cookieName != "" { + if ck, err := r.Cookie(d.cookieName); err == nil && ck.Value != "" { + return ck.Value + } + } + if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") { + return strings.TrimSpace(strings.TrimPrefix(hdr, "Bearer ")) + } + return "" +} + +// parse verifies a JWT against the shared HMAC secret and returns its claims. +func (d *JWTDetector) parse(tokenString string) (jwt.MapClaims, bool) { + if len(d.secret) == 0 { + // Fail closed: an unset shared secret must never accept a token. + // A detector wired up with an empty secret is a misconfiguration, and + // accepting an empty-key-signed token here would be a trivial auth + // bypass. Rejecting everything means SSO silently falls back to login. + return nil, false + } + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("invalid signing algorithm") + } + return d.secret, nil + }) + if err != nil || !token.Valid { + return nil, false + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, false + } + return claims, true +} diff --git a/server/go/web/sso_test.go b/server/go/web/sso_test.go new file mode 100644 index 0000000..f828740 --- /dev/null +++ b/server/go/web/sso_test.go @@ -0,0 +1,180 @@ +package web + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" + "github.com/yuanyuanxiang/SimpleRemoter/server/go/wsauth" +) + +const testSSOSecret = "test-shared-secret" + +func signTestToken(t *testing.T, secret string, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, err := tok.SignedString([]byte(secret)) + if err != nil { + t.Fatalf("sign test token: %v", err) + } + return s +} + +// noneToken builds a base64url header/claims JWT with alg "none" and no +// signature, to exercise the algorithm-confusion rejection path. +func noneToken(claims jwt.MapClaims) string { + h, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"}) + c, _ := json.Marshal(claims) + enc := base64.RawURLEncoding.EncodeToString + return strings.TrimRight(enc(h), "=") + "." + strings.TrimRight(enc(c), "=") + "." +} + +func cookieReq(token string) *http.Request { + r := &http.Request{Header: http.Header{}} + r.AddCookie(&http.Cookie{Name: "auth_token", Value: token}) + return r +} + +func bearerReq(token string) *http.Request { + r := &http.Request{Header: http.Header{}} + r.Header.Set("Authorization", "Bearer "+token) + return r +} + +func TestJWTDetector(t *testing.T) { + d := NewJWTDetector(JWTDetectorConfig{Secret: testSSOSecret}) + + valid := func(rolename string) jwt.MapClaims { + return jwt.MapClaims{ + "username": "alice", + "rolename": rolename, + "exp": time.Now().Add(time.Hour).Unix(), + } + } + + tests := []struct { + name string + req *http.Request + wantUser string + wantRole string + wantOK bool + }{ + {"admin maps to admin", cookieReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true}, + {"viewer maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("viewer"))), "alice", "viewer", true}, + {"empty rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid(""))), "alice", "viewer", true}, + {"unknown rolename maps to viewer", cookieReq(signTestToken(t, testSSOSecret, valid("operator"))), "alice", "viewer", true}, + {"bearer header accepted", bearerReq(signTestToken(t, testSSOSecret, valid("admin"))), "alice", "admin", true}, + {"no credential", &http.Request{Header: http.Header{}}, "", "", false}, + {"wrong secret rejected", cookieReq(signTestToken(t, "other-secret", valid("admin"))), "", "", false}, + {"expired token rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{ + "username": "alice", "rolename": "admin", "exp": time.Now().Add(-time.Hour).Unix(), + })), "", "", false}, + {"missing username rejected", cookieReq(signTestToken(t, testSSOSecret, jwt.MapClaims{ + "rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(), + })), "", "", false}, + {"alg none rejected", cookieReq(noneToken(valid("admin"))), "", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + user, role, ok := d.Detect(tt.req) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v (user=%q role=%q)", ok, tt.wantOK, user, role) + } + if !tt.wantOK { + return + } + if user != tt.wantUser || role != tt.wantRole { + t.Fatalf("got (%q, %q), want (%q, %q)", user, role, tt.wantUser, tt.wantRole) + } + }) + } +} + +// A detector with no shared secret must never accept a token, even one signed +// with an empty key — otherwise a misconfigured deployment would silently +// accept forged sessions. +func TestJWTDetectorEmptySecretFailClosed(t *testing.T) { + d := NewJWTDetector(JWTDetectorConfig{}) + tok := signTestToken(t, "", jwt.MapClaims{ + "username": "alice", "rolename": "admin", "exp": time.Now().Add(time.Hour).Unix(), + }) + if _, _, ok := d.Detect(cookieReq(tok)); ok { + t.Fatal("empty-secret detector accepted a token; expected fail-closed") + } +} + +// Non-default config keys must be honored, so a different portal can reuse the +// detector with no code changes. +func TestJWTDetectorCustomConfig(t *testing.T) { + d := NewJWTDetector(JWTDetectorConfig{ + Secret: testSSOSecret, + CookieName: "session", + UsernameClaim: "sub", + RoleClaim: "role", + AdminRole: "root", + }) + tok := signTestToken(t, testSSOSecret, jwt.MapClaims{ + "sub": "bob", "role": "root", "exp": time.Now().Add(time.Hour).Unix(), + }) + r := &http.Request{Header: http.Header{}} + r.AddCookie(&http.Cookie{Name: "session", Value: tok}) + user, role, ok := d.Detect(r) + if !ok || user != "bob" || role != "admin" { + t.Fatalf("got (%q, %q, %v), want (%q, %q, true)", user, role, ok, "bob", "admin") + } +} + +// fakeSSODetector lets the /sso handler's role policy be exercised without a +// real portal JWT. +type fakeSSODetector struct { + username, role string + ok bool +} + +func (f *fakeSSODetector) Detect(*http.Request) (string, string, bool) { + return f.username, f.role, f.ok +} + +// The /sso handler must only bridge administrators. A valid portal session for +// any other role — or no session at all — returns 401 so the front-end falls +// back to the built-in login form. +func TestHandleSSOAdminOnly(t *testing.T) { + newSrv := func(d SSODetector) *Server { + return &Server{ + log: logger.New(logger.Config{}), + auth: wsauth.New(), + sso: d, + } + } + + tests := []struct { + name string + detector SSODetector + wantStatus int + }{ + {"admin is bridged", &fakeSSODetector{"alice", "admin", true}, http.StatusOK}, + {"viewer is rejected", &fakeSSODetector{"bob", "viewer", true}, http.StatusUnauthorized}, + {"unknown role is rejected", &fakeSSODetector{"carol", "operator", true}, http.StatusUnauthorized}, + {"no credential is rejected", &fakeSSODetector{ok: false}, http.StatusUnauthorized}, + {"SSO disabled", nil, http.StatusUnauthorized}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := newSrv(tt.detector) + rec := httptest.NewRecorder() + s.handleSSO(rec, httptest.NewRequest(http.MethodPost, "/sso", nil)) + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tt.wantStatus, rec.Body.String()) + } + }) + } +} diff --git a/server/go/wsauth/wsauth.go b/server/go/wsauth/wsauth.go index 10be342..e85e33e 100644 --- a/server/go/wsauth/wsauth.go +++ b/server/go/wsauth/wsauth.go @@ -258,7 +258,6 @@ func (a *Authenticator) fakeSalt(username string) string { func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, role string, err error) { a.mu.RLock() u, ok := a.users[username] - expire := a.tokenExpire a.mu.RUnlock() if !ok { return "", "", errors.New("invalid credentials") @@ -268,18 +267,30 @@ func (a *Authenticator) VerifyLogin(username, response, nonce string) (token, ro return "", "", errors.New("invalid credentials") } - token, err = randomHex(tokenBytes) + token, err = a.IssueToken(username, u.Role) if err != nil { return "", "", err } + return token, u.Role, nil +} + +// IssueToken mints and stores a fresh session token for an identity that has +// already been authenticated out-of-band (SSO), skipping the challenge +// response. username is used only for audit/logging; authorization keys on +// Role. The caller must pass a role already normalized to "admin" or "viewer". +func (a *Authenticator) IssueToken(username, role string) (string, error) { + token, err := randomHex(tokenBytes) + if err != nil { + return "", err + } a.mu.Lock() a.tokens[token] = &Session{ Username: username, - Role: u.Role, - ExpiresAt: time.Now().Add(expire), + Role: role, + ExpiresAt: time.Now().Add(a.tokenExpire), } a.mu.Unlock() - return token, u.Role, nil + return token, nil } // usersFileEntry is the on-disk shape of one record in users.json. Field diff --git a/server/web/index.html b/server/web/index.html index 95f3555..11af653 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -1670,6 +1670,26 @@ } } + // Attempt single sign-on via the portal's session cookie. On success + // the server returns a YAMA session token + role, which we persist + // exactly like a local login; the caller then connects the WS and skips + // the login form. Returns true on success, false to fall back to login. + async function trySSO() { + try { + const resp = await fetch('__BASE_PATH__/sso', { credentials: 'same-origin' }); + if (!resp.ok) return false; + const data = await resp.json(); + if (!data || !data.token) return false; + token = data.token; + currentUserRole = data.role || 'viewer'; + sessionStorage.setItem('token', token); + sessionStorage.setItem('role', currentUserRole); + return true; + } catch (e) { + return false; + } + } + function connectWebSocket() { if (!isPageVisible) return; // Don't connect in background const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -4480,14 +4500,21 @@ bindKeyboardBtnEvents(document.getElementById('qc-keyboard')); bindKeyboardBtnEvents(document.getElementById('btn-keyboard')); bindKeyboardBtnEvents(document.getElementById('btn-keyboard-bar')); - // Restore token and role from sessionStorage + // Restore token and role from sessionStorage (local-login fallback). token = sessionStorage.getItem('token'); currentUserRole = sessionStorage.getItem('role') || 'viewer'; - // Show Users button for admin only (will be updated after login verification) - if (token && currentUserRole === 'admin') { - document.getElementById('users-btn').classList.add('visible'); - } - connectWebSocket(); + // Attempt portal SSO first: if the user is already logged into the + // parent domain, the server bridges the portal session cookie into + // a YAMA session token so the login form is skipped. On failure + // (not logged in, or SSO disabled) fall back to the restored + // session or the built-in login form. + trySSO().then(() => { + // Show Users button for admin only (final role known after SSO). + if (token && currentUserRole === 'admin') { + document.getElementById('users-btn').classList.add('visible'); + } + connectWebSocket(); + }); };