Skip to content

Commit

Permalink
Broadcast Turbo Streams over WebSockets with the Action Cable protocol (
Browse files Browse the repository at this point in the history
#29)

* Add prototype for Action Cable and Turbo Streams route handlers

* Work around bug in clean-css level 2 which breaks border color style

* Turn the session store into an interface, flatten the server Globals

* Use HMAC to generate & check signed turbo stream names

* Use TS/AC to broadcast changes to network device membership lists

* Add topic cancellation to the pubsub hub

* Decompose out logic for running with interval

* Add optional CSWSH protection, move stream name hash to separate attr

* Update two more turbo frame IDs
  • Loading branch information
ethanjli authored Apr 8, 2022
1 parent a05f6c3 commit 7db9e6e
Show file tree
Hide file tree
Showing 93 changed files with 3,445 additions and 768 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ You'll need to set some environment variables to tell Fluitans how to assign nam
- SESSIONS_COOKIE_NOHTTPSONLY, which should be `true` if you are running Fluitans locally (as `localhost`) without HTTPS. If you are running Fluitans over the web, you should run it behind an HTTPS reverse proxy and you should leave SESSION_COOKIE_NOHTTPSONLY unset.
- SESSIONS_AUTH_KEY, which should be set to a session key generated by running Fluitans without the SESSION_AUTH_KEY set.
- AUTHN_ADMIN_PW_HASH, which should be set to the password hash generated by running Fluitans with a password set as AUTHN_ADMIN_PW.
- TURBOSTREAMS_HASH_KEY, which should be set to an HMAC key generated by running Fluitans without the TURBOSTREAMS_HASH_KEY set.

For example, you could generate the password and session key using:
```
Expand All @@ -38,8 +39,8 @@ which will print a message like:
Record this admin password hash for future use as AUTHN_ADMIN_PW_HASH
(use single-quotes from shell to avoid string substitution with dollar-signs):
$argon2id$v=19$m=65536,t=1,p=2$EIV/HJ0DILHeNf2IC+qsGQ$BvBCCEsKUCKuAPI+pzM+sbCy/pdQdOF/FmHwx/yIusU
Record this key for future use as SESSIONS_AUTH_KEY:
QVG4y5EPPoDZjAzYc6j7I09iJum3w+hXNrB3O4HQvSc=
Record this key for future use as SESSIONS_AUTH_KEY: QVG4y5EPPoDZjAzYc6j7I09iJum3w+hXNrB3O4HQvSc=
Record this key for future use as TURBOSTREAMS_HASH_KEY: S+daMZsQxsqjmINunGWJhXvvxcgJtqnACba+sFuC4Tc=
```

And then you could run the server in development mode (which you can log into with username `admin` and password `mypassword`) using:
Expand All @@ -51,6 +52,7 @@ DNS_SERVER='https://desec.io' \
DNS_AUTHTOKEN='abcdefghijklmn0123456789' \
SESSION_AUTH_KEY='QVG4y5EPPoDZjAzYc6j7I09iJum3w+hXNrB3O4HQvSc=' \
AUTHN_ADMIN_PW_HASH='$argon2id$v=19$m=65536,t=1,p=2$EIV/HJ0DILHeNf2IC+qsGQ$BvBCCEsKUCKuAPI+pzM+sbCy/pdQdOF/FmHwx/yIusU' \
TURBOSTREAMS_HASH_KEY='S+daMZsQxsqjmINunGWJhXvvxcgJtqnACba+sFuC4Tc=' \
make run
```

Expand All @@ -63,6 +65,7 @@ DNS_SERVER='https://desec.io' \
DNS_AUTHTOKEN='abcdefghijklmn0123456789' \
SESSION_AUTH_KEY='QVG4y5EPPoDZjAzYc6j7I09iJum3w+hXNrB3O4HQvSc=' \
AUTHN_ADMIN_PW_HASH='$argon2id$v=19$m=65536,t=1,p=2$EIV/HJ0DILHeNf2IC+qsGQ$BvBCCEsKUCKuAPI+pzM+sbCy/pdQdOF/FmHwx/yIusU' \
TURBOSTREAMS_HASH_KEY='S+daMZsQxsqjmINunGWJhXvvxcgJtqnACba+sFuC4Tc=' \
./fluitans
```

Expand Down
3 changes: 2 additions & 1 deletion cmd/fluitans/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"fmt"

"github.com/labstack/echo/v4"
Expand Down Expand Up @@ -32,6 +33,6 @@ func main() {
s.Register(e)

// Start server
go s.RunBackgroundWorkers()
go s.RunBackgroundWorkers(context.TODO())
e.Logger.Fatal(e.Start(fmt.Sprintf(":%d", port)))
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/gorilla/csrf v1.7.1
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/gorilla/websocket v1.5.0
github.com/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.12 // indirect
github.com/labstack/echo/v4 v4.7.2
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
Expand Down
48 changes: 43 additions & 5 deletions internal/app/fluitans/auth/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/pkg/errors"

"github.com/sargassum-world/fluitans/pkg/godest/session"
"github.com/sargassum-world/fluitans/pkg/godest/turbostreams"
)

// Authorization
Expand All @@ -16,7 +18,9 @@ func (a Auth) Authorized() bool {
return a.Identity.Authenticated
}

func (a Auth) RequireAuthorized() error {
// HTTP

func (a Auth) RequireHTTPAuthz() error {
if a.Authorized() {
return nil
}
Expand All @@ -28,17 +32,51 @@ func (a Auth) RequireAuthorized() error {
return echo.NewHTTPError(http.StatusNotFound, "unauthorized")
}

func RequireAuthz(sc *session.Client) echo.MiddlewareFunc {
func RequireHTTPAuthz(ss session.Store) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
a, _, err := GetWithSession(c.Request(), sc, c.Logger())
a, _, err := GetWithSession(c.Request(), ss, c.Logger())
if err != nil {
return err
}
if err = a.RequireAuthorized(); err != nil {
if err = a.RequireHTTPAuthz(); err != nil {
return err
}
// TODO: store the auth in the request context
return next(c)
}
}
}

// Turbo Streams

func (a Auth) RequireTSAuthz() error {
if a.Authorized() {
return nil
}

if a.Identity.User == "" {
return errors.New("unknown user not authorized")
}
return errors.Errorf("user %s not authorized", a.Identity.User)
}

func RequireTSAuthz(ss session.Store) turbostreams.MiddlewareFunc {
return func(next turbostreams.HandlerFunc) turbostreams.HandlerFunc {
return func(c turbostreams.Context) error {
sess, err := ss.Lookup(c.SessionID())
if err != nil {
return errors.Errorf("couldn't lookup session to check authz on %s", c.Topic())
}
if sess == nil {
return errors.Errorf("unknown user not authorized on %s", c.Topic())
}
a, err := GetWithoutRequest(*sess, ss)
if err != nil {
return errors.Wrap(err, "couldn't lookup auth info for session")
}
if err = a.RequireTSAuthz(); err != nil {
return errors.Wrapf(err, "couldn't authorize on %s", c.Topic())
}
return next(c)
}
}
Expand Down
89 changes: 0 additions & 89 deletions internal/app/fluitans/auth/middleware.go

This file was deleted.

79 changes: 76 additions & 3 deletions internal/app/fluitans/auth/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,56 @@
package auth

import (
"encoding/gob"
"net/http"

"github.com/gorilla/csrf"
"github.com/gorilla/sessions"
"github.com/pkg/errors"

"github.com/sargassum-world/fluitans/pkg/godest/session"
)

type Auth struct {
Identity Identity
CSRF CSRF
}

// Identity

type Identity struct {
Authenticated bool
User string
}

func SetIdentity(s *sessions.Session, username string) {
identity := Identity{
Authenticated: username != "",
User: username,
}
s.Values["identity"] = identity
gob.Register(identity)
}

func GetIdentity(s sessions.Session) (identity Identity, err error) {
if s.IsNew {
return Identity{}, nil
}

rawIdentity, ok := s.Values["identity"]
if !ok {
// A zero value for Identity indicates that the session has no identity associated with it
return Identity{}, nil
}
identity, ok = rawIdentity.(Identity)
if !ok {
return Identity{}, errors.Errorf("unexpected type for field identity in session")
}
return identity, nil
}

// CSRF

type CSRFBehavior struct {
InlineToken bool
}
Expand All @@ -20,7 +62,38 @@ type CSRF struct {
Token string
}

type Auth struct {
Identity Identity
CSRF CSRF
func SetCSRFBehavior(s *sessions.Session, inlineToken bool) {
behavior := CSRFBehavior{
InlineToken: inlineToken,
}
s.Values["csrfBehavior"] = behavior
gob.Register(behavior)
}

func GetCSRFBehavior(s sessions.Session) (behavior CSRFBehavior, err error) {
if s.IsNew {
return CSRFBehavior{}, nil
}

rawBehavior, ok := s.Values["csrfBehavior"]
if !ok {
// By default, HTML responses won't inline the CSRF input fields (so responses can be cached),
// because the app only allows POST requests after user authentication. This default behavior
// can be overridden, e.g. on the login form for user authentication, with OverrideCSRFInlining.
return CSRFBehavior{}, nil
}
behavior, ok = rawBehavior.(CSRFBehavior)
if !ok {
return CSRFBehavior{}, errors.Errorf("unexpected type for field csrfBehavior in session")
}
return behavior, nil
}

func (c *CSRF) SetInlining(r *http.Request, inlineToken bool) {
c.Behavior.InlineToken = inlineToken
if c.Behavior.InlineToken {
c.Token = csrf.Token(r)
} else {
c.Token = ""
}
}
Loading

0 comments on commit 7db9e6e

Please sign in to comment.