feat(backend): start on fediverse auth support

This commit is contained in:
Sam 2023-03-16 11:43:25 +01:00
parent bfa810fbb2
commit 17f6ac4d23
No known key found for this signature in database
GPG Key ID: B4EF20DDE721CAA1
7 changed files with 354 additions and 36 deletions

76
backend/db/fediverse.go Normal file
View File

@ -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
}

View File

@ -28,6 +28,10 @@ type User struct {
Discord *string Discord *string
DiscordUsername *string DiscordUsername *string
Fediverse *string
FediverseUsername *string
FediverseAppID *int64
MaxInvites int MaxInvites int
DeletedAt *time.Time DeletedAt *time.Time

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -17,6 +17,7 @@ type Server struct {
*server.Server *server.Server
RequireInvite bool RequireInvite bool
BaseURL string
ExporterPath string ExporterPath string
} }
@ -55,6 +56,7 @@ func Mount(srv *server.Server, r chi.Router) {
s := &Server{ s := &Server{
Server: srv, Server: srv,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
BaseURL: os.Getenv("BASE_URL"),
ExporterPath: "http://127.0.0.1:" + os.Getenv("EXPORTER_PORT"), 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 // generate csrf token, returns all supported OAuth provider URLs
r.Post("/urls", server.WrapHandler(s.oauthURLs)) r.Post("/urls", server.WrapHandler(s.oauthURLs))
r.Get("/urls/fediverse", server.WrapHandler(s.getFediverseURL))
r.Route("/discord", func(r chi.Router) { r.Route("/discord", func(r chi.Router) {
// takes code + state, validates it, returns token OR discord signup ticket // takes code + state, validates it, returns token OR discord signup ticket
r.Post("/callback", server.WrapHandler(s.discordCallback)) r.Post("/callback", server.WrapHandler(s.discordCallback))
// takes discord signup ticket to register account // takes discord signup ticket to register account
r.Post("/signup", server.WrapHandler(s.discordSignup)) 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 // invite routes

View File

@ -93,6 +93,7 @@ const (
ErrInviteAlreadyUsed = 1010 // invite already used (when signing up) ErrInviteAlreadyUsed = 1010 // invite already used (when signing up)
ErrDeletionPending = 1011 // own user deletion pending, returned with undo code ErrDeletionPending = 1011 // own user deletion pending, returned with undo code
ErrRecentExport = 1012 // latest export is too recent ErrRecentExport = 1012 // latest export is too recent
ErrUnsupportedInstance = 1013 // unsupported fediverse software
// User-related error codes // User-related error codes
ErrUserNotFound = 2001 ErrUserNotFound = 2001
@ -128,6 +129,7 @@ var errCodeMessages = map[int]string{
ErrInviteAlreadyUsed: "That invite code has already been used", ErrInviteAlreadyUsed: "That invite code has already been used",
ErrDeletionPending: "Your account is pending deletion", ErrDeletionPending: "Your account is pending deletion",
ErrRecentExport: "Your latest data export is less than 1 day old", ErrRecentExport: "Your latest data export is less than 1 day old",
ErrUnsupportedInstance: "Unsupported instance software",
ErrUserNotFound: "User not found", ErrUserNotFound: "User not found",
@ -160,6 +162,7 @@ var errCodeStatuses = map[int]int{
ErrInviteAlreadyUsed: http.StatusBadRequest, ErrInviteAlreadyUsed: http.StatusBadRequest,
ErrDeletionPending: http.StatusBadRequest, ErrDeletionPending: http.StatusBadRequest,
ErrRecentExport: http.StatusBadRequest, ErrRecentExport: http.StatusBadRequest,
ErrUnsupportedInstance: http.StatusBadRequest,
ErrUserNotFound: http.StatusNotFound, ErrUserNotFound: http.StatusNotFound,

View File

@ -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;