Feature: Add admin-only SSO bridge to web UI
Bridge an already-authenticated portal session into a YAMA web session via a new /sso endpoint, so portal administrators can enter the remote-desktop UI without a second login. Only the "admin" portal role is bridged; any other role (viewer, unknown) still falls back to the built-in login form, so a low-privileged portal account cannot bypass authentication. - wsauth: extract IssueToken from VerifyLogin so an out-of-band-authenticated identity (SSO) can mint a session token without the challenge/response. - web: add the SSODetector interface, a fail-closed JWT detector (HMAC-only), and the /sso handler gated to the admin role. - cmd: enable the JWT detector via YAMA_SSO_JWT_SECRET, the shared HS256 secret matching the parent portal. - web UI: attempt SSO before showing the login form, falling back to login when the server returns 401. - docs: document SSO (admin-only) and license trial-vs-paid troubleshooting in README and the env example. Co-Authored-By: deepseek-v4-pro
This commit is contained in:
1 parent
d2149851b2
commit
53b7151fb7
9 files changed
+499
-11
No files matched your search
@@ -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. | `<shared-secret>` |
|
||||
|
||||
```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:<IP>` 表示下级请求**根本没带 `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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user