feat(backend): start on fediverse auth support
This commit is contained in:
parent
bfa810fbb2
commit
17f6ac4d23
7 changed files with 354 additions and 36 deletions
76
backend/db/fediverse.go
Normal file
76
backend/db/fediverse.go
Normal 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
|
||||
}
|
|
@ -28,6 +28,10 @@ type User struct {
|
|||
Discord *string
|
||||
DiscordUsername *string
|
||||
|
||||
Fediverse *string
|
||||
FediverseUsername *string
|
||||
FediverseAppID *int64
|
||||
|
||||
MaxInvites int
|
||||
|
||||
DeletedAt *time.Time
|
||||
|
|
95
backend/routes/auth/fedi_nodeinfo.go
Normal file
95
backend/routes/auth/fedi_nodeinfo.go
Normal 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"`
|
||||
}
|
114
backend/routes/auth/fediverse.go
Normal file
114
backend/routes/auth/fediverse.go
Normal 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"`
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
15
scripts/migrate/009_fediverse_oauth.sql
Normal file
15
scripts/migrate/009_fediverse_oauth.sql
Normal 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;
|
Loading…
Reference in a new issue