223 lines
5.7 KiB
Go
223 lines
5.7 KiB
Go
package gonexapp
|
|
|
|
import (
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// AccessGate enforces Required Groups for the Requesting user on ExApp HTTP traffic.
|
|
type AccessGate struct {
|
|
Cred Credentials
|
|
Groups []string
|
|
CacheTTL time.Duration // 0 disables cache
|
|
ExtraSkipPaths []string
|
|
Client *http.Client
|
|
OCS OCSClient
|
|
Now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
cache map[string]cacheEntry
|
|
}
|
|
|
|
type cacheEntry struct {
|
|
until time.Time
|
|
}
|
|
|
|
// CheckResult is the outcome of AccessGate.Check.
|
|
type CheckResult int
|
|
|
|
const (
|
|
// CheckAllowed means the request may proceed (or the gate is inactive / skipped).
|
|
CheckAllowed CheckResult = iota
|
|
// CheckDenied means the Requesting user is not in Required Groups.
|
|
CheckDenied
|
|
// CheckUnauthorized means no Requesting user could be read from the request.
|
|
CheckUnauthorized
|
|
// CheckUnavailable means group membership could not be determined (e.g. OCS error).
|
|
CheckUnavailable
|
|
)
|
|
|
|
// Wrap returns a handler that runs Check before next.
|
|
// CheckAllowed calls next.
|
|
// CheckUnauthorized writes 401. CheckUnavailable writes 503.
|
|
// CheckDenied writes English HTML with status 200 and frame-ancestors 'self'
|
|
// when the request accepts text/html, and 403 otherwise.
|
|
// An empty Groups list allows every request. Paths /heartbeat, /enabled,
|
|
// /init, and /js/ are skipped, plus ExtraSkipPaths.
|
|
func (g AccessGate) Wrap(next http.Handler) http.Handler {
|
|
gate := g.normalized()
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch gate.Check(r) {
|
|
case CheckAllowed:
|
|
next.ServeHTTP(w, r)
|
|
case CheckUnauthorized:
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
case CheckUnavailable:
|
|
http.Error(w, "service unavailable", http.StatusServiceUnavailable)
|
|
default:
|
|
gate.writeDenied(w, r)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Check reports whether r may proceed under Required Groups.
|
|
func (g *AccessGate) Check(r *http.Request) CheckResult {
|
|
if g.cache == nil {
|
|
g.cache = make(map[string]cacheEntry)
|
|
}
|
|
if g.Now == nil {
|
|
g.Now = time.Now
|
|
}
|
|
if g.shouldSkip(r.URL.Path) || len(g.Groups) == 0 {
|
|
return CheckAllowed
|
|
}
|
|
user, err := UserFromRequest(r)
|
|
if err != nil || user == "" {
|
|
return CheckUnauthorized
|
|
}
|
|
ok, err := g.memberOfRequired(user)
|
|
if err != nil {
|
|
return CheckUnavailable
|
|
}
|
|
if ok {
|
|
return CheckAllowed
|
|
}
|
|
return CheckDenied
|
|
}
|
|
|
|
func (g *AccessGate) memberOfRequired(userID string) (bool, error) {
|
|
key := g.cacheKey(userID)
|
|
if g.CacheTTL > 0 {
|
|
if g.cachedAllowed(key) {
|
|
return true, nil
|
|
}
|
|
}
|
|
groups, err := g.fetchUserGroups(userID)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, need := range g.Groups {
|
|
if slices.Contains(groups, need) {
|
|
if g.CacheTTL > 0 {
|
|
g.storeAllowed(key)
|
|
}
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (g *AccessGate) cacheKey(userID string) string {
|
|
return userID + "\x00" + strings.Join(g.Groups, "\x00")
|
|
}
|
|
|
|
func (g *AccessGate) fetchUserGroups(userID string) ([]string, error) {
|
|
return Groups{Cred: g.Cred, Client: g.Client, OCS: g.OCS}.UserGroups(userID)
|
|
}
|
|
|
|
func (g *AccessGate) cachedAllowed(key string) bool {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
ent, ok := g.cache[key]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if g.Now().After(ent.until) {
|
|
delete(g.cache, key)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (g *AccessGate) storeAllowed(key string) {
|
|
g.mu.Lock()
|
|
defer g.mu.Unlock()
|
|
g.cache[key] = cacheEntry{until: g.Now().Add(g.CacheTTL)}
|
|
}
|
|
|
|
func (g AccessGate) normalized() *AccessGate {
|
|
out := g
|
|
if out.cache == nil {
|
|
out.cache = make(map[string]cacheEntry)
|
|
}
|
|
if out.Now == nil {
|
|
out.Now = time.Now
|
|
}
|
|
return &out
|
|
}
|
|
|
|
func (g *AccessGate) writeDenied(w http.ResponseWriter, r *http.Request) {
|
|
if acceptsHTML(r.Header.Get("Accept")) {
|
|
// 200 plus frame-ancestors 'self': AppAPI's default proxy CSP uses
|
|
// frame-ancestors 'none' unless the ExApp sets CSP, which blanks iframes.
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Content-Security-Policy", deniedCSP)
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(deniedHTML)
|
|
return
|
|
}
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
}
|
|
|
|
func acceptsHTML(accept string) bool {
|
|
return strings.Contains(strings.ToLower(accept), "text/html")
|
|
}
|
|
|
|
func (g *AccessGate) shouldSkip(path string) bool {
|
|
path = normalizeGatePath(path)
|
|
if isTopMenuScriptPath(path) {
|
|
return true
|
|
}
|
|
if slices.Contains(defaultSkipPaths, path) {
|
|
return true
|
|
}
|
|
for _, p := range g.ExtraSkipPaths {
|
|
if path == normalizeGatePath(p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isTopMenuScriptPath reports AppAPI top-menu bootstrap scripts under /js/.
|
|
// Those must load for a non-member so the shell can show Denied UI; gating
|
|
// them yields a blank embedded page (script Accept is not text/html → 403).
|
|
func isTopMenuScriptPath(path string) bool {
|
|
return path == "/js" || strings.HasPrefix(path, "/js/")
|
|
}
|
|
|
|
func normalizeGatePath(path string) string {
|
|
path = strings.TrimSuffix(path, "/")
|
|
if path == "" {
|
|
return "/"
|
|
}
|
|
return path
|
|
}
|
|
|
|
var defaultSkipPaths = []string{"/heartbeat", "/enabled", "/init"}
|
|
|
|
// deniedCSP lets AppAPI proxy the denied page into an ExApp iframe.
|
|
const deniedCSP = "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; style-src 'unsafe-inline'"
|
|
|
|
var deniedHTML = []byte(`<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Access denied</title>
|
|
<style>
|
|
:root { font-family: ui-sans-serif, system-ui, sans-serif; color: #1a1a1a; }
|
|
body { margin: 2rem; max-width: 40rem; }
|
|
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
|
|
p { color: #444; line-height: 1.5; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Access denied</h1>
|
|
<p>You are not a member of a required group for this app. Ask an administrator to add you to the group if you need access.</p>
|
|
</body>
|
|
</html>
|
|
`)
|