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
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user