diff --git a/backend/db/fediverse.go b/backend/db/fediverse.go new file mode 100644 index 0000000..acb8dc2 --- /dev/null +++ b/backend/db/fediverse.go @@ -0,0 +1,76 @@ +package db + +import ( + "context" + "os" + + "emperror.dev/errors" + "github.com/georgysavva/scany/pgxscan" + "github.com/jackc/pgx/v4" + "golang.org/x/oauth2" +) + +type FediverseApp struct { + ID int64 + // Instance is the instance's base API url, excluding schema + Instance string + ClientID string + ClientSecret string + InstanceType string +} + +func (f FediverseApp) ClientConfig() *oauth2.Config { + // if f.MastodonCompatible() { + return &oauth2.Config{ + ClientID: f.ClientID, + ClientSecret: f.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://" + f.Instance + "/oauth/authorize", + TokenURL: "https://" + f.Instance + "/oauth/token", + AuthStyle: oauth2.AuthStyleInParams, + }, + Scopes: []string{"read:accounts"}, + RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon", + } + // } + + // TODO: misskey, assuming i can even find english API documentation, that is +} + +func (f FediverseApp) MastodonCompatible() bool { + return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" +} + +const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app") + +func (db *DB) FediverseApp(ctx context.Context, instance string) (fa FediverseApp, err error) { + sql, args, err := sq.Select("*").From("fediverse_apps").Where("instance = ?", instance).ToSql() + if err != nil { + return fa, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &fa, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return fa, ErrNoInstanceApp + } + return fa, errors.Wrap(err, "executing query") + } + return fa, nil +} + +func (db *DB) CreateFediverseApp(ctx context.Context, instance, instanceType, clientID, clientSecret string) (fa FediverseApp, err error) { + sql, args, err := sq.Insert("fediverse_apps"). + Columns("instance", "instance_type", "client_id", "client_secret"). + Values(instance, instanceType, clientID, clientSecret). + Suffix("RETURNING *").ToSql() + if err != nil { + return fa, errors.Wrap(err, "building query") + } + + err = pgxscan.Get(ctx, db, &fa, sql, args...) + if err != nil { + return fa, errors.Wrap(err, "executing query") + } + return fa, nil +} diff --git a/backend/db/user.go b/backend/db/user.go index 36134b2..803b842 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -28,6 +28,10 @@ type User struct { Discord *string DiscordUsername *string + Fediverse *string + FediverseUsername *string + FediverseAppID *int64 + MaxInvites int DeletedAt *time.Time diff --git a/backend/routes/auth/fedi_nodeinfo.go b/backend/routes/auth/fedi_nodeinfo.go new file mode 100644 index 0000000..4df63ee --- /dev/null +++ b/backend/routes/auth/fedi_nodeinfo.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" +) + +const errNoNodeinfoURL = errors.Sentinel("no valid nodeinfo rel found") + +// nodeinfo queries an instance's nodeinfo and returns the software name. +func nodeinfo(ctx context.Context, instance string) (softwareName string, err error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://"+instance+"/.well-known/nodeinfo", nil) + if err != nil { + return "", errors.Wrap(err, "creating .well-known/nodeinfo request") + } + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "sending .well-known/nodeinfo request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "reading .well-known/nodeinfo response") + } + + var wkr wellKnownResponse + err = json.Unmarshal(jb, &wkr) + if err != nil { + return "", errors.Wrap(err, "unmarshaling .well-known/nodeinfo response") + } + + var nodeinfoURL string + for _, link := range wkr.Links { + if link.Rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" { + nodeinfoURL = link.Href + break + } + } + + if nodeinfoURL == "" { + return "", errNoNodeinfoURL + } + + req, err = http.NewRequestWithContext(ctx, "GET", nodeinfoURL, nil) + if err != nil { + return "", errors.Wrap(err, "creating nodeinfo request") + } + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Accept", "application/json") + + resp, err = http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "sending nodeinfo request") + } + defer resp.Body.Close() + + jb, err = io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "reading nodeinfo response") + } + + var ni partialNodeinfo + err = json.Unmarshal(jb, &ni) + if err != nil { + return "", errors.Wrap(err, "unmarshaling nodeinfo response") + } + + return ni.Software.Name, nil +} + +type wellKnownResponse struct { + Links []wellKnownLink `json:"links"` +} + +type wellKnownLink struct { + Rel string `json:"rel"` + Href string `json:"href"` +} + +type partialNodeinfo struct { + Software nodeinfoSoftware `json:"software"` +} + +type nodeinfoSoftware struct { + Name string `json:"name"` +} diff --git a/backend/routes/auth/fediverse.go b/backend/routes/auth/fediverse.go new file mode 100644 index 0000000..e321084 --- /dev/null +++ b/backend/routes/auth/fediverse.go @@ -0,0 +1,114 @@ +package auth + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" +) + +type FediResponse struct { + URL string `json:"url"` +} + +func (s *Server) getFediverseURL(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + instance := r.FormValue("instance") + if instance == "" { + return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"} + } + + app, err := s.DB.FediverseApp(ctx, instance) + if err != nil { + return s.noAppFediverseURL(ctx, w, r, instance) + } + + state, err := s.setCSRFState(r.Context()) + if err != nil { + return errors.Wrap(err, "setting CSRF state") + } + + render.JSON(w, r, FediResponse{ + URL: app.ClientConfig().AuthCodeURL(state), + }) + return nil +} + +func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r *http.Request, instance string) error { + softwareName, err := nodeinfo(ctx, instance) + if err != nil { + log.Errorf("querying instance %q nodeinfo: %v", instance, err) + } + + switch softwareName { + case "mastodon", "pleroma", "akkoma", "pixelfed", "calckey": + default: + // sorry, misskey :( TODO: support misskey + return server.APIError{Code: server.ErrUnsupportedInstance} + } + + log.Debugf("creating application on mastodon-compatible instance %q", instance) + + formData := url.Values{ + "client_name": {"pronouns.cc (+" + s.BaseURL + ")"}, + "redirect_uris": {s.BaseURL + "/auth/login/mastodon"}, + "scopes": {"read:accounts"}, + "website": {s.BaseURL}, + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/v1/apps", strings.NewReader(formData.Encode())) + if err != nil { + log.Errorf("creating POST apps request for %q: %v", instance, err) + return errors.Wrap(err, "creating POST apps request") + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + log.Errorf("sending POST apps request for %q: %v", instance, err) + return errors.Wrap(err, "sending POST apps request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + log.Errorf("reading response for request: %v", err) + return errors.Wrap(err, "reading response") + } + + var ma mastodonApplication + err = json.Unmarshal(jb, &ma) + if err != nil { + return errors.Wrap(err, "unmarshaling mastodon app") + } + + app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ClientID, ma.ClientSecret) + if err != nil { + log.Errorf("saving app for %q: %v", instance, err) + return errors.Wrap(err, "creating app") + } + + state, err := s.setCSRFState(r.Context()) + if err != nil { + return errors.Wrap(err, "setting CSRF state") + } + + render.JSON(w, r, FediResponse{ + URL: app.ClientConfig().AuthCodeURL(state), + }) + return nil +} + +type mastodonApplication struct { + Name string `json:"name"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 39d1a61..f18144f 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -17,6 +17,7 @@ type Server struct { *server.Server RequireInvite bool + BaseURL string ExporterPath string } @@ -55,6 +56,7 @@ func Mount(srv *server.Server, r chi.Router) { s := &Server{ Server: srv, RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", + BaseURL: os.Getenv("BASE_URL"), ExporterPath: "http://127.0.0.1:" + os.Getenv("EXPORTER_PORT"), } @@ -64,12 +66,21 @@ func Mount(srv *server.Server, r chi.Router) { // generate csrf token, returns all supported OAuth provider URLs r.Post("/urls", server.WrapHandler(s.oauthURLs)) + r.Get("/urls/fediverse", server.WrapHandler(s.getFediverseURL)) r.Route("/discord", func(r chi.Router) { // takes code + state, validates it, returns token OR discord signup ticket r.Post("/callback", server.WrapHandler(s.discordCallback)) // takes discord signup ticket to register account r.Post("/signup", server.WrapHandler(s.discordSignup)) + // takes discord signup ticket to link to existing account + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil)) + }) + + r.Route("/mastodon", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(nil)) + r.Post("/signup", server.WrapHandler(nil)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil)) }) // invite routes diff --git a/backend/server/errors.go b/backend/server/errors.go index bbe35d2..976e7e3 100644 --- a/backend/server/errors.go +++ b/backend/server/errors.go @@ -81,18 +81,19 @@ const ( ErrInternalServerError = 500 // catch-all code for unknown errors // Login/authorize error codes - ErrInvalidState = 1001 - ErrInvalidOAuthCode = 1002 - ErrInvalidToken = 1003 // a token was supplied, but it is invalid - ErrInviteRequired = 1004 - ErrInvalidTicket = 1005 // invalid signup ticket - ErrInvalidUsername = 1006 // invalid username (when signing up) - ErrUsernameTaken = 1007 // username taken (when signing up) - ErrInvitesDisabled = 1008 // invites are disabled (unneeded) - ErrInviteLimitReached = 1009 // invite limit reached (when creating invites) - ErrInviteAlreadyUsed = 1010 // invite already used (when signing up) - ErrDeletionPending = 1011 // own user deletion pending, returned with undo code - ErrRecentExport = 1012 // latest export is too recent + ErrInvalidState = 1001 + ErrInvalidOAuthCode = 1002 + ErrInvalidToken = 1003 // a token was supplied, but it is invalid + ErrInviteRequired = 1004 + ErrInvalidTicket = 1005 // invalid signup ticket + ErrInvalidUsername = 1006 // invalid username (when signing up) + ErrUsernameTaken = 1007 // username taken (when signing up) + ErrInvitesDisabled = 1008 // invites are disabled (unneeded) + ErrInviteLimitReached = 1009 // invite limit reached (when creating invites) + ErrInviteAlreadyUsed = 1010 // invite already used (when signing up) + ErrDeletionPending = 1011 // own user deletion pending, returned with undo code + ErrRecentExport = 1012 // latest export is too recent + ErrUnsupportedInstance = 1013 // unsupported fediverse software // User-related error codes ErrUserNotFound = 2001 @@ -116,18 +117,19 @@ var errCodeMessages = map[int]string{ ErrTooManyRequests: "Rate limit reached", ErrMethodNotAllowed: "Method not allowed", - ErrInvalidState: "Invalid OAuth state", - ErrInvalidOAuthCode: "Invalid OAuth code", - ErrInvalidToken: "Supplied token was invalid", - ErrInviteRequired: "A valid invite code is required", - ErrInvalidTicket: "Invalid signup ticket", - ErrInvalidUsername: "Invalid username", - ErrUsernameTaken: "Username is already taken", - ErrInvitesDisabled: "Invites are disabled", - ErrInviteLimitReached: "Your account has reached the invite limit", - ErrInviteAlreadyUsed: "That invite code has already been used", - ErrDeletionPending: "Your account is pending deletion", - ErrRecentExport: "Your latest data export is less than 1 day old", + ErrInvalidState: "Invalid OAuth state", + ErrInvalidOAuthCode: "Invalid OAuth code", + ErrInvalidToken: "Supplied token was invalid", + ErrInviteRequired: "A valid invite code is required", + ErrInvalidTicket: "Invalid signup ticket", + ErrInvalidUsername: "Invalid username", + ErrUsernameTaken: "Username is already taken", + ErrInvitesDisabled: "Invites are disabled", + ErrInviteLimitReached: "Your account has reached the invite limit", + ErrInviteAlreadyUsed: "That invite code has already been used", + ErrDeletionPending: "Your account is pending deletion", + ErrRecentExport: "Your latest data export is less than 1 day old", + ErrUnsupportedInstance: "Unsupported instance software", ErrUserNotFound: "User not found", @@ -148,18 +150,19 @@ var errCodeStatuses = map[int]int{ ErrTooManyRequests: http.StatusTooManyRequests, ErrMethodNotAllowed: http.StatusMethodNotAllowed, - ErrInvalidState: http.StatusBadRequest, - ErrInvalidOAuthCode: http.StatusForbidden, - ErrInvalidToken: http.StatusUnauthorized, - ErrInviteRequired: http.StatusBadRequest, - ErrInvalidTicket: http.StatusBadRequest, - ErrInvalidUsername: http.StatusBadRequest, - ErrUsernameTaken: http.StatusBadRequest, - ErrInvitesDisabled: http.StatusForbidden, - ErrInviteLimitReached: http.StatusForbidden, - ErrInviteAlreadyUsed: http.StatusBadRequest, - ErrDeletionPending: http.StatusBadRequest, - ErrRecentExport: http.StatusBadRequest, + ErrInvalidState: http.StatusBadRequest, + ErrInvalidOAuthCode: http.StatusForbidden, + ErrInvalidToken: http.StatusUnauthorized, + ErrInviteRequired: http.StatusBadRequest, + ErrInvalidTicket: http.StatusBadRequest, + ErrInvalidUsername: http.StatusBadRequest, + ErrUsernameTaken: http.StatusBadRequest, + ErrInvitesDisabled: http.StatusForbidden, + ErrInviteLimitReached: http.StatusForbidden, + ErrInviteAlreadyUsed: http.StatusBadRequest, + ErrDeletionPending: http.StatusBadRequest, + ErrRecentExport: http.StatusBadRequest, + ErrUnsupportedInstance: http.StatusBadRequest, ErrUserNotFound: http.StatusNotFound, diff --git a/scripts/migrate/009_fediverse_oauth.sql b/scripts/migrate/009_fediverse_oauth.sql new file mode 100644 index 0000000..f1265cc --- /dev/null +++ b/scripts/migrate/009_fediverse_oauth.sql @@ -0,0 +1,15 @@ +-- +migrate Up + +-- 2023-03-16: Add fediverse (Mastodon/Pleroma/Misskey) OAuth + +create table fediverse_apps ( + id serial primary key, + instance text not null unique, + client_id text not null, + client_secret text not null, + instance_type text not null +); + +alter table users add column fediverse text null; +alter table users add column fediverse_username text null; +alter table users add column fediverse_app_id integer null references fediverse_apps (id) on delete set null;