diff --git a/plugin.go b/plugin.go index 445f906..586fadd 100644 --- a/plugin.go +++ b/plugin.go @@ -12,6 +12,7 @@ import ( "git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/jakub/sm-bm-plugin/classes" "git.golem15.com/jakub/sm-bm-plugin/console" "git.golem15.com/jakub/sm-bm-plugin/controllers" "github.com/go-gormigrate/gormigrate/v2" @@ -30,6 +31,7 @@ var ( _ pact.AdminAssets = (*Plugin)(nil) _ pact.HasPermissions = (*Plugin)(nil) _ pact.HasNavigation = (*Plugin)(nil) + _ pact.HasMiddleware = (*Plugin)(nil) ) //go:embed config @@ -54,6 +56,9 @@ type Plugin struct { // realtime is the app's lighthouse service, built at Boot. Nil when // Boot did not run, as in tests that build routes on a bare plugin. realtime *lighthouse.Service + // chatMember is the chat-group check of the realtime access guard; nil + // means classes.IsChatMember. Tests inject their own. + chatMember classes.MemberFunc } func (p *Plugin) ID() string { return "jz.bm" } diff --git a/realtime_access.go b/realtime_access.go new file mode 100644 index 0000000..d934edf --- /dev/null +++ b/realtime_access.go @@ -0,0 +1,105 @@ +package bm + +import ( + "net/http" + "sync" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/wire" + "git.golem15.com/jakub/sm-bm-plugin/classes" +) + +// MiddlewareRealtimeAccess guards the realtime connection-token route: a +// token is minted only for someone with a realtime consumer, a chat-group +// member or a presentation owner. It runs after jwt.auth. +const MiddlewareRealtimeAccess = "jz.bm.realtime-access" + +// presentationsOwnerGuard is golem15.presentations' owner guard, named +// here without importing that plugin. +const presentationsOwnerGuard = "presentations.realtime-owner" + +// Middlewares exports the realtime access guard. +func (p *Plugin) Middlewares() map[string]pact.Middleware { + return map[string]pact.Middleware{MiddlewareRealtimeAccess: p.realtimeAccess} +} + +// realtimeAccess lets a chat-group member through, otherwise, when +// golem15.presentations is active, whoever its owner guard lets through +// (it answers its own refusals), and refuses everyone else. The owner guard +// is looked up once, by name, among the activated plugins. With BM's own +// Centrifugo instance the tokens it mints are valid for BM only. +func (p *Plugin) realtimeAccess(next http.Handler) http.Handler { + var once sync.Once + var owner http.Handler + ownerGuard := func() http.Handler { + once.Do(func() { + if mw := p.presentationsMiddleware(presentationsOwnerGuard); mw != nil { + owner = mw(next) + } + }) + return owner + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := bouncer.User(r.Context()) + if !ok || user == nil || user.ID == 0 { + writeRealtimeGuard(w, http.StatusUnauthorized, "Unauthorized") + return + } + db := p.database() + if db == nil { + writeRealtimeGuard(w, http.StatusServiceUnavailable, "Service unavailable") + return + } + if p.app.HasPlugin("golem15.chat") { + isMember := p.chatMember + if isMember == nil { + isMember = classes.IsChatMember + } + member, err := isMember(r.Context(), db, user.ID, p.chatGroup()) + if err != nil { + writeRealtimeGuard(w, http.StatusServiceUnavailable, "Service unavailable") + return + } + if member { + next.ServeHTTP(w, r) + return + } + } + if p.app.HasPlugin("golem15.presentations") { + if h := ownerGuard(); h != nil { + h.ServeHTTP(w, r) + return + } + } + writeRealtimeGuard(w, http.StatusForbidden, "Forbidden") + }) +} + +// presentationsMiddleware returns the named middleware of the activated +// golem15.presentations plugin, or nil. +func (p *Plugin) presentationsMiddleware(name string) pact.Middleware { + if p.app == nil { + return nil + } + activated, ok := p.app.Lookup[party.Activated]() + if !ok { + return nil + } + for _, pl := range activated { + if pl.ID() != "golem15.presentations" { + continue + } + if hm, ok := pl.(pact.HasMiddleware); ok { + return hm.Middlewares()[name] + } + } + return nil +} + +// writeRealtimeGuard answers in the token route's own error shape. +func writeRealtimeGuard(w http.ResponseWriter, status int, message string) { + w.Header().Set("Cache-Control", "no-cache, private") + wire.WriteJSON(w, status, map[string]string{"error": message}) +} diff --git a/routes.go b/routes.go index a0578f7..bad3c09 100644 --- a/routes.go +++ b/routes.go @@ -203,23 +203,27 @@ func (p *Plugin) Routes(r pact.Router) error { // mountRealtime mounts the realtime driver's routes: with the Centrifugo // driver the connection-token route (user) and the subscribe proxy -// (server-to-server, authenticated by its shared secret). The phone remote -// of golem15.presentations is the only realtime consumer, so nothing is -// mounted without it, and the token route is limited to presentation owners -// by its presentations.realtime-owner guard, named here without importing -// the plugin. Only the token route is throttled, per user. The subscribe -// proxy is called by Centrifugo from 127.0.0.1 for every client, so any -// throttle there would be one shared bucket that a single token holder -// could exhaust and lock the owner out; it relies on its constant-time -// secret check and Centrifugo's own limits instead. Routes built without -// Boot (bare tests) mount nothing; the default null driver declares no -// routes. +// (server-to-server, authenticated by its shared secret). The realtime +// consumers are the BM chat (golem15.chat) and the phone remote of +// golem15.presentations, so nothing is mounted without either, and the +// token route is limited to chat-group members and presentation owners by +// jz.bm.realtime-access, which reaches the presentations guard by name +// without importing the plugin. Only the token route is throttled, per +// user. The subscribe proxy is called by Centrifugo from 127.0.0.1 for +// every client, so any throttle there would be one shared bucket that a +// single token holder could exhaust and lock everyone out; it relies on its +// constant-time secret check and Centrifugo's own limits instead. Routes +// built without Boot (bare tests) mount nothing; the default null driver +// declares no routes. func (p *Plugin) mountRealtime(r pact.Router) error { - if p.realtime == nil || p.app == nil || !p.app.HasPlugin("golem15.presentations") { + if p.realtime == nil || p.app == nil { + return nil + } + if !p.app.HasPlugin("golem15.chat") && !p.app.HasPlugin("golem15.presentations") { return nil } return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{ - UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime), + UserAuth: surf.Use("jwt.auth", MiddlewareRealtimeAccess, bucketRealtime), ServerToServer: surf.Use(), Middleware: surf.Use(), }) diff --git a/static.go b/static.go index 0aaae6b..d75d79a 100644 --- a/static.go +++ b/static.go @@ -37,10 +37,10 @@ const ( const defaultAdminPath = "/bmadmin" // reservedPrefixes are the path prefixes the student app never answers: the -// APIs of the plugins, the public uploads and /oauth (the social sign-in -// routes of golem15.sociallogin and their provider callbacks). The admin -// path is added from the configuration. -var reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/_presentations", "/api", "/storage", "/oauth"} +// APIs of the plugins (the chat plugins' included), the public uploads and +// /oauth (the social sign-in routes of golem15.sociallogin and their +// provider callbacks). The admin path is added from the configuration. +var reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/_presentations", "/_chat", "/_chatchannels", "/_chatbots", "/api", "/storage", "/oauth"} // errMissingIndex reports a tree without index.html (an unbuilt tree). var errMissingIndex = errors.New("jz.bm: public/index.html missing; run the frontend build (cd frontend && npm ci && npm run build)")