diff --git a/appview/auth/auth.go b/appview/auth/auth.go index bedad78b..b689c5bb 100644 --- a/appview/auth/auth.go +++ b/appview/auth/auth.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "net/http" "time" @@ -13,10 +12,11 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" "github.com/bluesky-social/indigo/xrpc" "github.com/gorilla/sessions" + "github.com/icyphox/bild/appview" ) type Auth struct { - store sessions.Store + Store *sessions.CookieStore } type AtSessionCreate struct { @@ -30,7 +30,7 @@ type AtSessionRefresh struct { } func Make() (*Auth, error) { - store := sessions.NewCookieStore([]byte("TODO_CHANGE_ME")) + store := sessions.NewCookieStore([]byte(appview.SESSION_COOKIE_SECRET)) return &Auth{store}, nil } @@ -66,13 +66,89 @@ func (a *Auth) CreateInitialSession(ctx context.Context, username, appPassword s return atSession, nil } -func (a *Auth) StoreSession(r *http.Request, w http.ResponseWriter, atSession *comatproto.ServerCreateSession_Output) error { +// Sessionish is an interface that provides access to the common fields of both types. +type Sessionish interface { + GetAccessJwt() string + GetActive() *bool + GetDid() string + GetDidDoc() *interface{} + GetHandle() string + GetRefreshJwt() string + GetStatus() *string +} + +// Create a wrapper type for ServerRefreshSession_Output +type RefreshSessionWrapper struct { + *comatproto.ServerRefreshSession_Output +} + +func (s *RefreshSessionWrapper) GetAccessJwt() string { + return s.AccessJwt +} + +func (s *RefreshSessionWrapper) GetActive() *bool { + return s.Active +} + +func (s *RefreshSessionWrapper) GetDid() string { + return s.Did +} + +func (s *RefreshSessionWrapper) GetDidDoc() *interface{} { + return s.DidDoc +} + +func (s *RefreshSessionWrapper) GetHandle() string { + return s.Handle +} + +func (s *RefreshSessionWrapper) GetRefreshJwt() string { + return s.RefreshJwt +} + +func (s *RefreshSessionWrapper) GetStatus() *string { + return s.Status +} + +// Create a wrapper type for ServerRefreshSession_Output +type CreateSessionWrapper struct { + *comatproto.ServerCreateSession_Output +} + +func (s *CreateSessionWrapper) GetAccessJwt() string { + return s.AccessJwt +} + +func (s *CreateSessionWrapper) GetActive() *bool { + return s.Active +} + +func (s *CreateSessionWrapper) GetDid() string { + return s.Did +} + +func (s *CreateSessionWrapper) GetDidDoc() *interface{} { + return s.DidDoc +} + +func (s *CreateSessionWrapper) GetHandle() string { + return s.Handle +} + +func (s *CreateSessionWrapper) GetRefreshJwt() string { + return s.RefreshJwt +} + +func (s *CreateSessionWrapper) GetStatus() *string { + return s.Status +} + +func (a *Auth) StoreSession(r *http.Request, w http.ResponseWriter, atSessionish Sessionish) error { var didDoc identity.DIDDocument - bytes, _ := json.Marshal(atSession.DidDoc) + bytes, _ := json.Marshal(atSessionish.GetDidDoc()) err := json.Unmarshal(bytes, &didDoc) if err != nil { - log.Printf("did: %+v", *atSession.DidDoc) return fmt.Errorf("invalid did document for session") } @@ -83,14 +159,14 @@ func (a *Auth) StoreSession(r *http.Request, w http.ResponseWriter, atSession *c return fmt.Errorf("no pds endpoint found") } - clientSession, _ := a.store.Get(r, "appview-session") - clientSession.Values["handle"] = atSession.Handle - clientSession.Values["did"] = atSession.Did - clientSession.Values["pds"] = pdsEndpoint - clientSession.Values["accessJwt"] = atSession.AccessJwt - clientSession.Values["refreshJwt"] = atSession.RefreshJwt - clientSession.Values["expiry"] = time.Now().Add(time.Hour).String() - clientSession.Values["authenticated"] = true + clientSession, _ := a.Store.Get(r, appview.SESSION_NAME) + clientSession.Values[appview.SESSION_HANDLE] = atSessionish.GetHandle() + clientSession.Values[appview.SESSION_DID] = atSessionish.GetDid() + clientSession.Values[appview.SESSION_PDS] = pdsEndpoint + clientSession.Values[appview.SESSION_ACCESSJWT] = atSessionish.GetAccessJwt() + clientSession.Values[appview.SESSION_REFRESHJWT] = atSessionish.GetRefreshJwt() + clientSession.Values[appview.SESSION_EXPIRY] = time.Now().Add(time.Hour).Format(appview.TIME_LAYOUT) + clientSession.Values[appview.SESSION_AUTHENTICATED] = true return clientSession.Save(r, w) } diff --git a/appview/consts.go b/appview/consts.go new file mode 100644 index 00000000..1a7bb7c1 --- /dev/null +++ b/appview/consts.go @@ -0,0 +1,16 @@ +package appview + +const ( + SESSION_COOKIE_SECRET = "TODO_CHANGE_ME" + SESSION_NAME = "appview-session" + SESSION_HANDLE = "handle" + SESSION_DID = "did" + SESSION_PDS = "pds" + SESSION_ACCESSJWT = "accessJwt" + SESSION_REFRESHJWT = "refreshJwt" + SESSION_EXPIRY = "expiry" + SESSION_AUTHENTICATED = "authenticated" + + SALT = "TODO_RANDOM_SALT" + TIME_LAYOUT = "2006-01-02 15:04:05.999999999 -0700 MST" +) diff --git a/appview/db/db.go b/appview/db/db.go index e778ecec..07e37fcb 100644 --- a/appview/db/db.go +++ b/appview/db/db.go @@ -1,8 +1,14 @@ package db import ( + "context" "database/sql" + "fmt" + "log" + + "github.com/google/uuid" _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" ) type DB struct { @@ -14,6 +20,126 @@ func Make(dbPath string) (*DB, error) { if err != nil { return nil, err } - + _, err = db.Exec(` + create table if not exists registrations ( + id integer primary key autoincrement, + domain text not null unique, + did text not null, + secret text not null, + created integer default (strftime('%s', 'now')), + registered integer + ); + `) + if err != nil { + return nil, err + } return &DB{db: db}, nil } + +type RegStatus uint32 + +const ( + Registered RegStatus = iota + Unregistered + Pending +) + +// returns registered status, did of owner, error +func (d *DB) RegistrationStatus(domain string) (RegStatus, string, error) { + var registeredBy string + var registratedAt *uint64 + err := d.db.QueryRow(` + select did, registered from registrations + where domain = ? + `, domain).Scan(®isteredBy, ®istratedAt) + if err != nil { + if err == sql.ErrNoRows { + return Unregistered, "", nil + } else { + return Unregistered, "", err + } + } + + if registratedAt != nil { + return Registered, registeredBy, nil + } else { + return Pending, registeredBy, nil + } +} + +func (d *DB) GenerateRegistrationKey(domain, did string) (string, error) { + // sanity check: does this domain already have a registration? + status, owner, err := d.RegistrationStatus(domain) + if err != nil { + return "", err + } + switch status { + case Registered: + // already registered by `owner` + return "", fmt.Errorf("%s already registered by %s", domain, owner) + case Pending: + log.Printf("%s registered by %s, status pending", domain, owner) + // TODO: provide a warning here, and allow the current user to overwrite + // the registration, this prevents users from registering domains that they + // do not own + default: + // ok, we can register this domain + } + + secret := uuid.New().String() + hashedSecret, err := bcrypt.GenerateFromPassword([]byte(secret), 3) + + if err != nil { + return "", err + } + + _, err = d.db.Exec(` + insert into registrations (domain, did, secret) + values (?, ?, ?) + on conflict(domain) do update set did = excluded.did, secret = excluded.secret + `, domain, did, fmt.Sprintf("%x", hashedSecret)) + + if err != nil { + return "", err + } + + return secret, nil +} + +func (d *DB) Register(domain, secret string) error { + ctx := context.TODO() + + tx, err := d.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + res := tx.QueryRow(`select secret from registrations where domain = ?`, domain) + + var storedSecret string + err = res.Scan(&storedSecret) + if err != nil { + return err + } + + err = bcrypt.CompareHashAndPassword([]byte(storedSecret), []byte(secret)) + if err != nil { + return err + } + + _, err = tx.Exec(` + update registrations + set registered = strftime('%s', 'now') + where domain = ?; + `, domain) + if err != nil { + return err + } + + err = tx.Commit() + if err != nil { + return err + } + + return nil +} diff --git a/appview/state/middleware.go b/appview/state/middleware.go new file mode 100644 index 00000000..959afada --- /dev/null +++ b/appview/state/middleware.go @@ -0,0 +1,71 @@ +package state + +import ( + "log" + "net/http" + "time" + + comatproto "github.com/bluesky-social/indigo/api/atproto" + "github.com/bluesky-social/indigo/xrpc" + "github.com/icyphox/bild/appview" + "github.com/icyphox/bild/appview/auth" +) + +type Middleware func(http.Handler) http.Handler + +func AuthMiddleware(s *State) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, _ := s.Auth.Store.Get(r, appview.SESSION_NAME) + authorized, ok := session.Values[appview.SESSION_AUTHENTICATED].(bool) + + if !ok || !authorized { + log.Printf("not logged in, redirecting") + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) + return + } + + // refresh if nearing expiry + // TODO: dedup with /login + expiryStr := session.Values[appview.SESSION_EXPIRY].(string) + expiry, err := time.Parse(appview.TIME_LAYOUT, expiryStr) + if err != nil { + log.Println("invalid expiry time", err) + return + } + pdsUrl := session.Values[appview.SESSION_PDS].(string) + did := session.Values[appview.SESSION_DID].(string) + refreshJwt := session.Values[appview.SESSION_REFRESHJWT].(string) + + if time.Now().After(expiry) { + log.Println("token expired, refreshing ...") + + client := xrpc.Client{ + Host: pdsUrl, + Auth: &xrpc.AuthInfo{ + Did: did, + AccessJwt: refreshJwt, + RefreshJwt: refreshJwt, + }, + } + atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) + if err != nil { + log.Println(err) + return + } + + sessionish := auth.RefreshSessionWrapper{atSession} + + err = s.Auth.StoreSession(r, w, &sessionish) + if err != nil { + log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) + return + } + + log.Println("successfully refreshed token") + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/appview/state/state.go b/appview/state/state.go index 616912f8..8bb71272 100644 --- a/appview/state/state.go +++ b/appview/state/state.go @@ -1,10 +1,12 @@ package state import ( + "encoding/json" "log" "net/http" "github.com/go-chi/chi/v5" + "github.com/icyphox/bild/appview" "github.com/icyphox/bild/appview/auth" "github.com/icyphox/bild/appview/db" ) @@ -44,8 +46,9 @@ func (s *State) Login(w http.ResponseWriter, r *http.Request) { log.Printf("creating initial session: %s", err) return } + sessionish := auth.CreateSessionWrapper{atSession} - err = s.Auth.StoreSession(r, w, atSession) + err = s.Auth.StoreSession(r, w, &sessionish) if err != nil { log.Printf("storing session: %s", err) return @@ -57,10 +60,66 @@ func (s *State) Login(w http.ResponseWriter, r *http.Request) { } } +// requires auth +func (s *State) GenerateRegistrationKey(w http.ResponseWriter, r *http.Request) { + session, err := s.Auth.Store.Get(r, appview.SESSION_NAME) + if err != nil || session.IsNew { + log.Println("unauthorized attempt to generate registration key") + http.Error(w, "Forbidden", http.StatusUnauthorized) + return + } + + did := session.Values[appview.SESSION_DID].(string) + domain := r.FormValue("domain") + if domain == "" { + http.Error(w, "Invalid form", http.StatusBadRequest) + return + } + + key, err := s.Db.GenerateRegistrationKey(domain, did) + + if err != nil { + log.Println(err) + http.Error(w, "unable to register this domain", http.StatusNotAcceptable) + return + } + + w.Write([]byte(key)) + return +} + +type RegisterRequest struct { + Domain string `json:"domain"` + Secret string `json:"secret"` +} + +func (s *State) Register(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + log.Println("unimplemented") + return + case http.MethodPost: + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + domain := req.Domain + secret := req.Secret + + log.Printf("Registered domain: %s with secret: %s", domain, secret) + } +} + func (s *State) Router() http.Handler { r := chi.NewRouter() r.Post("/login", s.Login) + r.Group(func(r chi.Router) { + r.Use(AuthMiddleware(s)) + r.Post("/node/generate-key", s.GenerateRegistrationKey) + }) return r } diff --git a/go.mod b/go.mod index 610b4876..7b9c2ff0 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 github.com/sethvargo/go-envconfig v1.1.0 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e - golang.org/x/crypto v0.31.0 + golang.org/x/crypto v0.32.0 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 gopkg.in/yaml.v3 v3.0.1 ) @@ -97,7 +97,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/net v0.33.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/time v0.3.0 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index f4703624..5d198d11 100644 --- a/go.sum +++ b/go.sum @@ -282,8 +282,8 @@ golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -339,8 +339,8 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -348,8 +348,8 @@ golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=