From cf424d3ae4eabaff4f26e2e20d2bb5361546c220 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 16 Mar 2023 15:50:39 +0100 Subject: [PATCH] feat: sign up/log in with mastodon --- backend/db/fediverse.go | 2 +- backend/db/user.go | 41 +++ backend/routes/auth/discord.go | 8 +- backend/routes/auth/fedi_mastodon.go | 287 ++++++++++++++++++ backend/routes/auth/fediverse.go | 2 +- backend/routes/auth/routes.go | 4 +- .../login/mastodon/[instance]/+page.server.ts | 36 +++ .../login/mastodon/[instance]/+page.svelte | 137 +++++++++ 8 files changed, 509 insertions(+), 8 deletions(-) create mode 100644 backend/routes/auth/fedi_mastodon.go create mode 100644 frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts create mode 100644 frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte diff --git a/backend/db/fediverse.go b/backend/db/fediverse.go index acb8dc2..3d0e16c 100644 --- a/backend/db/fediverse.go +++ b/backend/db/fediverse.go @@ -30,7 +30,7 @@ func (f FediverseApp) ClientConfig() *oauth2.Config { AuthStyle: oauth2.AuthStyleInParams, }, Scopes: []string{"read:accounts"}, - RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon", + RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance, } // } diff --git a/backend/db/user.go b/backend/db/user.go index 803b842..5b1e38a 100644 --- a/backend/db/user.go +++ b/backend/db/user.go @@ -98,6 +98,47 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use return u, nil } +func (db *DB) FediverseUser(ctx context.Context, userID string, instanceAppID int64) (u User, err error) { + sql, args, err := sq.Select("*").From("users"). + Where("fediverse = ?", userID).Where("fediverse_app_id = ?", instanceAppID). + ToSql() + if err != nil { + return u, errors.Wrap(err, "building sql") + } + + err = pgxscan.Get(ctx, db, &u, sql, args...) + if err != nil { + if errors.Cause(err) == pgx.ErrNoRows { + return u, ErrUserNotFound + } + + return u, errors.Wrap(err, "executing query") + } + return u, nil +} + +func (u *User) UpdateFromFedi(ctx context.Context, ex Execer, userID, username string, appID int64) error { + sql, args, err := sq.Update("users"). + Set("fediverse", userID). + Set("fediverse_username", username). + Set("fediverse_app_id", appID). + Where("id = ?", u.ID). + ToSql() + if err != nil { + return errors.Wrap(err, "building sql") + } + + _, err = ex.Exec(ctx, sql, args...) + if err != nil { + return errors.Wrap(err, "executing query") + } + + u.Fediverse = &userID + u.FediverseUsername = &username + u.FediverseAppID = &appID + return nil +} + // DiscordUser fetches a user by Discord user ID. func (db *DB) DiscordUser(ctx context.Context, discordID string) (u User, err error) { sql, args, err := sq.Select("*").From("users").Where("discord = ?", discordID).ToSql() diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index 8e6f7ba..c067653 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -27,7 +27,7 @@ var discordOAuthConfig = oauth2.Config{ Scopes: []string{"identify"}, } -type oauthCallbackRequest struct { +type discordOauthCallbackRequest struct { CallbackDomain string `json:"callback_domain"` Code string `json:"code"` State string `json:"state"` @@ -50,7 +50,7 @@ type discordCallbackResponse struct { func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - decoded, err := Decode[oauthCallbackRequest](r) + decoded, err := Decode[discordOauthCallbackRequest](r) if err != nil { return server.APIError{Code: server.ErrBadRequest} } @@ -153,7 +153,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { return nil } -type signupRequest struct { +type discordSignupRequest struct { Ticket string `json:"ticket"` Username string `json:"username"` InviteCode string `json:"invite_code"` @@ -167,7 +167,7 @@ type signupResponse struct { func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() - req, err := Decode[signupRequest](r) + req, err := Decode[discordSignupRequest](r) if err != nil { return server.APIError{Code: server.ErrBadRequest} } diff --git a/backend/routes/auth/fedi_mastodon.go b/backend/routes/auth/fedi_mastodon.go new file mode 100644 index 0000000..49c1f73 --- /dev/null +++ b/backend/routes/auth/fedi_mastodon.go @@ -0,0 +1,287 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "time" + + "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 fediOauthCallbackRequest struct { + Instance string `json:"instance"` + Code string `json:"code"` + State string `json:"state"` +} + +type fediCallbackResponse struct { + HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Fediverse will be set + + Token string `json:"token,omitempty"` + User *userResponse `json:"user,omitempty"` + + Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes + Ticket string `json:"ticket,omitempty"` + RequireInvite bool `json:"require_invite"` // require an invite for signing up + + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +type partialMastodonAccount struct { + ID string `json:"id"` + Username string `json:"username"` +} + +func (s *Server) mastodonCallback(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, "GET", "https://"+decoded.Instance+"/api/v1/accounts/verify_credentials", nil) + if err != nil { + return errors.Wrap(err, "creating verify_credentials 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 verify_credentials request") + } + defer resp.Body.Close() + + jb, err := io.ReadAll(resp.Body) + if err != nil { + return errors.Wrap(err, "reading verify_credentials response") + } + + var mu partialMastodonAccount + err = json.Unmarshal(jb, &mu) + if err != nil { + return errors.Wrap(err, "unmarshaling verify_credentials response") + } + + u, err := s.DB.FediverseUser(ctx, mu.ID, app.ID) + if err == nil { + if u.DeletedAt != nil && *u.SelfDelete { + // 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, + }) + return nil + } + + err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID) + if err != nil { + log.Errorf("updating user %v with mastoAPI info: %v", u.ID, err) + } + + // TODO: implement user + token permissions + tokenID := xid.New() + token, err := s.Auth.CreateToken(u.ID, tokenID, false, 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 Mastodon info in Redis + ticket := RandBase64(32) + err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600") + if err != nil { + log.Errorf("setting mastoAPI 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 +} + +type fediSignupRequest struct { + Instance string `json:"instance"` + Ticket string `json:"ticket"` + Username string `json:"username"` + InviteCode string `json:"invite_code"` +} + +func (s *Server) mastodonSignup(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(partialMastodonAccount) + err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu) + if err != nil { + log.Errorf("getting mastoAPI user for ticket: %v", err) + + return server.APIError{Code: server.ErrInvalidTicket} + } + + u, err := s.DB.CreateUser(ctx, tx, req.Username) + if err != nil { + return errors.Wrap(err, "creating user") + } + + err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID) + if err != nil { + if errors.Cause(err) == db.ErrUsernameTaken { + return server.APIError{Code: server.ErrUsernameTaken} + } + + return errors.Wrap(err, "updating user from mastoAPI") + } + + 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", "mastodon:"+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 +} diff --git a/backend/routes/auth/fediverse.go b/backend/routes/auth/fediverse.go index e321084..6532cba 100644 --- a/backend/routes/auth/fediverse.go +++ b/backend/routes/auth/fediverse.go @@ -58,7 +58,7 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r formData := url.Values{ "client_name": {"pronouns.cc (+" + s.BaseURL + ")"}, - "redirect_uris": {s.BaseURL + "/auth/login/mastodon"}, + "redirect_uris": {s.BaseURL + "/auth/login/mastodon/" + instance}, "scopes": {"read:accounts"}, "website": {s.BaseURL}, } diff --git a/backend/routes/auth/routes.go b/backend/routes/auth/routes.go index 452d240..601af27 100644 --- a/backend/routes/auth/routes.go +++ b/backend/routes/auth/routes.go @@ -76,8 +76,8 @@ func Mount(srv *server.Server, r chi.Router) { }) r.Route("/mastodon", func(r chi.Router) { - r.Post("/callback", server.WrapHandler(nil)) - r.Post("/signup", server.WrapHandler(nil)) + r.Post("/callback", server.WrapHandler(s.mastodonCallback)) + r.Post("/signup", server.WrapHandler(s.mastodonSignup)) r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil)) }) diff --git a/frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts b/frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts new file mode 100644 index 0000000..36d5220 --- /dev/null +++ b/frontend/src/routes/auth/login/mastodon/[instance]/+page.server.ts @@ -0,0 +1,36 @@ +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/mastodon/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; +} diff --git a/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte b/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte new file mode 100644 index 0000000..a232900 --- /dev/null +++ b/frontend/src/routes/auth/login/mastodon/[instance]/+page.svelte @@ -0,0 +1,137 @@ + + + + Log in with Mastodon - pronouns.cc + + +

Log in with Mastodon

+ +{#if data.error} + +{/if} +{#if data.ticket} +
+
+ + +
+
+ + +
+ {#if data.require_invite} +
+ + +
+ You currently need an invite code to sign up. You can get + one from an existing user. +
+
+ {/if} +
+ By signing up, you agree to the terms of service and the + privacy policy. +
+ +
+{:else if data.is_deleted && data.token} +

Your account is pending deletion since {data.deleted_at}.

+

If you wish to cancel deletion, press the button below.

+

+ +

+ {#if deleteCancelled} + + Account deletion cancelled! You can now log in again. + + {/if} + {#if deleteError} + + {/if} +{:else} + Loading... +{/if}