From 987ff4770431af0ee1f035dfd2fbcc885fd9716c Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 25 Mar 2023 03:27:40 +0100 Subject: [PATCH] feat: misskey oauth (fixes #26) --- backend/db/fediverse.go | 28 +- backend/routes/auth/fedi_misskey.go | 384 ++++++++++++++++++ backend/routes/auth/fediverse.go | 4 +- backend/routes/auth/routes.go | 6 + .../login/misskey/[instance]/+page.server.ts | 38 ++ .../login/misskey/[instance]/+page.svelte | 66 +++ 6 files changed, 516 insertions(+), 10 deletions(-) create mode 100644 backend/routes/auth/fedi_misskey.go create mode 100644 frontend/src/routes/auth/login/misskey/[instance]/+page.server.ts create mode 100644 frontend/src/routes/auth/login/misskey/[instance]/+page.svelte diff --git a/backend/db/fediverse.go b/backend/db/fediverse.go index 8ebfa50..3a110d5 100644 --- a/backend/db/fediverse.go +++ b/backend/db/fediverse.go @@ -20,21 +20,31 @@ type FediverseApp struct { } func (f FediverseApp) ClientConfig() *oauth2.Config { - // if f.MastodonCompatible() { + 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/" + f.Instance, + } + } + 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, + AuthURL: "https://" + f.Instance + "/auth", + TokenURL: "https://" + f.Instance + "/api/auth/session/oauth", + AuthStyle: oauth2.AuthStyleInHeader, }, - Scopes: []string{"read:accounts"}, - RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance, + Scopes: []string{"read:account"}, + RedirectURL: os.Getenv("BASE_URL") + "/auth/login/misskey/" + f.Instance, } - // } - - // TODO: misskey, assuming i can even find english API documentation, that is } func (f FediverseApp) MastodonCompatible() bool { diff --git a/backend/routes/auth/fedi_misskey.go b/backend/routes/auth/fedi_misskey.go new file mode 100644 index 0000000..71ced10 --- /dev/null +++ b/backend/routes/auth/fedi_misskey.go @@ -0,0 +1,384 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "codeberg.org/u1f320/pronouns.cc/backend/db" + "codeberg.org/u1f320/pronouns.cc/backend/log" + "codeberg.org/u1f320/pronouns.cc/backend/server" + "emperror.dev/errors" + "github.com/go-chi/render" + "github.com/mediocregopher/radix/v4" + "github.com/rs/xid" +) + +type partialMisskeyAccount struct { + ID string `json:"id"` + Username string `json:"username"` +} + +func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + decoded, err := Decode[fediOauthCallbackRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + // if the state can't be validated, return + if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { + if err != nil { + return err + } + + return server.APIError{Code: server.ErrInvalidState} + } + + app, err := s.DB.FediverseApp(ctx, decoded.Instance) + if err != nil { + log.Errorf("getting app for instance %q: %v", decoded.Instance, err) + + if err == db.ErrNoInstanceApp { + // can we get here? + return server.APIError{Code: server.ErrNotFound} + } + } + + token, err := app.ClientConfig().Exchange(ctx, decoded.Code) + if err != nil { + log.Errorf("exchanging oauth code: %v", err) + + return server.APIError{Code: server.ErrInvalidOAuthCode} + } + + // make me user request + req, err := http.NewRequestWithContext(ctx, "POST", "https://"+decoded.Instance+"/api/i", nil) + if err != nil { + return errors.Wrap(err, "creating i request") + } + req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag) + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", token.Type()+" "+token.AccessToken) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "sending i request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "reading i response") + } + + var mu partialMisskeyAccount + err = json.Unmarshal(jb, &mu) + if err != nil { + return errors.Wrap(err, "unmarshaling i response") + } + + u, err := s.DB.FediverseUser(ctx, mu.ID, app.ID) + if err == nil { + if u.DeletedAt != nil { + // store cancel delete token + token := undeleteToken() + err = s.saveUndeleteToken(ctx, u.ID, token) + if err != nil { + log.Errorf("saving undelete token: %v", err) + return err + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, []db.Field{}), + IsDeleted: true, + DeletedAt: u.DeletedAt, + SelfDelete: u.SelfDelete, + DeleteReason: u.DeleteReason, + }) + return nil + } + + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) + if err != nil { + log.Errorf("updating user %v with misskey info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) + if err != nil { + return err + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "querying fields") + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: true, + Token: token, + User: dbUserToUserResponse(u, fields), + }) + + return nil + + } else if err != db.ErrUserNotFound { // internal error + return err + } + + // no user found, so save a ticket + save their Misskey info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu, "EX", "600") + if err != nil { + log.Errorf("setting misskey user for ticket %q: %v", ticket, err) + return err + } + + render.JSON(w, r, fediCallbackResponse{ + HasAccount: false, + Fediverse: mu.Username, + Ticket: ticket, + RequireInvite: s.RequireInvite, + }) + + return nil +} + +func (s *Server) misskeyLink(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + claims, _ := server.ClaimsFromContext(ctx) + + // only site tokens can be used for this endpoint + if claims.APIToken || !claims.TokenWrite { + return server.APIError{Code: server.ErrInvalidToken} + } + + req, err := Decode[fediLinkRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + app, err := s.DB.FediverseApp(ctx, req.Instance) + if err != nil { + return errors.Wrap(err, "getting instance application") + } + + u, err := s.DB.User(ctx, claims.UserID) + if err != nil { + return errors.Wrap(err, "getting user") + } + + if u.Fediverse != nil { + return server.APIError{Code: server.ErrAlreadyLinked} + } + + mu := new(partialMisskeyAccount) + err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu) + if err != nil { + log.Errorf("getting misskey user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) + if err != nil { + return errors.Wrap(err, "updating user from misskey") + } + + fields, err := s.DB.UserFields(ctx, u.ID) + if err != nil { + return errors.Wrap(err, "getting user fields") + } + + render.JSON(w, r, dbUserToUserResponse(u, fields)) + return nil +} + +func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + + req, err := Decode[fediSignupRequest](r) + if err != nil { + return server.APIError{Code: server.ErrBadRequest} + } + + if s.RequireInvite && req.InviteCode == "" { + return server.APIError{Code: server.ErrInviteRequired} + } + + app, err := s.DB.FediverseApp(ctx, req.Instance) + if err != nil { + return errors.Wrap(err, "getting instance application") + } + + valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) + if err != nil { + return err + } + if !valid { + return server.APIError{Code: server.ErrInvalidUsername} + } + if taken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + tx, err := s.DB.Begin(ctx) + if err != nil { + return errors.Wrap(err, "beginning transaction") + } + defer tx.Rollback(ctx) + + mu := new(partialMisskeyAccount) + err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu) + if err != nil { + log.Errorf("getting misskey user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "creating user") + } + + err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) + if err != nil { + return errors.Wrap(err, "updating user from misskey") + } + + if s.RequireInvite { + valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode) + if err != nil { + return errors.Wrap(err, "checking and invalidating invite") + } + + if !valid { + return server.APIError{Code: server.ErrInviteRequired} + } + + if used { + return server.APIError{Code: server.ErrInviteAlreadyUsed} + } + } + + // delete sign up ticket + err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "misskey:"+req.Ticket)) + if err != nil { + return errors.Wrap(err, "deleting signup ticket") + } + + // commit transaction + err = tx.Commit(ctx) + if err != nil { + return errors.Wrap(err, "committing transaction") + } + + // create token + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true) + if err != nil { + return errors.Wrap(err, "creating token") + } + + // save token to database + _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + if err != nil { + return errors.Wrap(err, "saving token to database") + } + + // return user + render.JSON(w, r, signupResponse{ + User: *dbUserToUserResponse(u, nil), + Token: token, + }) + return nil +} + +func (s *Server) noAppMisskeyURL(ctx context.Context, w http.ResponseWriter, r *http.Request, softwareName, instance string) error { + log.Debugf("creating application on misskey-compatible instance %q", instance) + + b, err := json.Marshal(misskeyAppRequest{ + Name: "pronouns.cc (+" + s.BaseURL + ")", + Description: "pronouns.cc on " + s.BaseURL, + CallbackURL: s.BaseURL + "/auth/login/misskey/" + instance, + Permission: []string{"read:account"}, + }) + if err != nil { + log.Errorf("marshaling app json: %v", err) + return errors.Wrap(err, "marshaling json") + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/app/create", bytes.NewReader(b)) + 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/json") + 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 misskeyApp + err = json.Unmarshal(jb, &ma) + if err != nil { + return errors.Wrap(err, "unmarshaling misskey app") + } + + app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ID, ma.Secret) + 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 misskeyAppRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Permission []string `json:"permission"` + CallbackURL string `json:"callbackUrl"` +} + +type misskeyApp struct { + ID string `json:"id"` + Secret string `json:"secret"` +} diff --git a/backend/routes/auth/fediverse.go b/backend/routes/auth/fediverse.go index 6532cba..f831b93 100644 --- a/backend/routes/auth/fediverse.go +++ b/backend/routes/auth/fediverse.go @@ -48,7 +48,9 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r } switch softwareName { - case "mastodon", "pleroma", "akkoma", "pixelfed", "calckey": + case "misskey", "foundkey", "calckey": + return s.noAppMisskeyURL(ctx, w, r, softwareName, instance) + case "mastodon", "pleroma", "akkoma", "pixelfed": default: // sorry, misskey :( TODO: support misskey return server.APIError{Code: server.ErrUnsupportedInstance} diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index fd833a3..923f57a 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -91,6 +91,12 @@ func Mount(srv *server.Server, r chi.Router) { r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.mastodonUnlink)) }) + r.Route("/misskey", func(r chi.Router) { + r.Post("/callback", server.WrapHandler(s.misskeyCallback)) + r.Post("/signup", server.WrapHandler(s.misskeySignup)) + r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.misskeyLink)) + }) + // invite routes r.With(server.MustAuth).Get("/invites", server.WrapHandler(s.getInvites)) r.With(server.MustAuth).Post("/invites", server.WrapHandler(s.createInvite)) diff --git a/frontend/src/routes/auth/login/misskey/[instance]/+page.server.ts b/frontend/src/routes/auth/login/misskey/[instance]/+page.server.ts new file mode 100644 index 0000000..c862cc5 --- /dev/null +++ b/frontend/src/routes/auth/login/misskey/[instance]/+page.server.ts @@ -0,0 +1,38 @@ +import type { APIError, MeUser } from "$lib/api/entities"; +import { apiFetch } from "$lib/api/fetch"; +import type { PageServerLoad } from "./$types"; + +export const load = (async ({ url, params }) => { + try { + const resp = await apiFetch("/auth/misskey/callback", { + method: "POST", + body: { + instance: params.instance, + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }, + }); + + return { + ...resp, + instance: params.instance, + }; + } catch (e) { + return { error: e as APIError }; + } +}) satisfies PageServerLoad; + +interface CallbackResponse { + has_account: boolean; + token?: string; + user?: MeUser; + + fediverse?: string; + ticket?: string; + require_invite: boolean; + + is_deleted: boolean; + deleted_at?: string; + self_delete?: boolean; + delete_reason?: string; +} diff --git a/frontend/src/routes/auth/login/misskey/[instance]/+page.svelte b/frontend/src/routes/auth/login/misskey/[instance]/+page.svelte new file mode 100644 index 0000000..ed9465f --- /dev/null +++ b/frontend/src/routes/auth/login/misskey/[instance]/+page.svelte @@ -0,0 +1,66 @@ + + +