From 2716471fa9f786c8e9929e9742b43d4cd9b702fa Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 30 Mar 2023 16:50:30 +0200 Subject: [PATCH] feat: add API tokens + force log out button --- backend/db/tokens.go | 13 ++- backend/routes/auth/discord.go | 4 +- backend/routes/auth/fedi_mastodon.go | 4 +- backend/routes/auth/fedi_misskey.go | 4 +- backend/routes/auth/tokens.go | 59 ++++++++-- frontend/src/routes/settings/+page.svelte | 58 +++++++++- .../src/routes/settings/tokens/+page.svelte | 107 +++++++++++++----- frontend/src/routes/settings/tokens/+page.ts | 4 +- scripts/migrate/011_token_info.sql | 6 + 9 files changed, 207 insertions(+), 52 deletions(-) create mode 100644 scripts/migrate/011_token_info.sql diff --git a/backend/db/tokens.go b/backend/db/tokens.go index b812dae..70c87cb 100644 --- a/backend/db/tokens.go +++ b/backend/db/tokens.go @@ -14,6 +14,8 @@ type Token struct { UserID xid.ID TokenID xid.ID Invalidated bool + APIOnly bool `db:"api_only"` + ReadOnly bool Created time.Time Expires time.Time } @@ -62,10 +64,15 @@ func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error) const ExpiryTime = 3 * 30 * 24 * time.Hour // SaveToken saves a token to the database. -func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID) (t Token, err error) { +func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) { sql, args, err := sq.Insert("tokens"). - Columns("user_id", "token_id", "expires"). - Values(userID, tokenID, time.Now().Add(ExpiryTime)). + SetMap(map[string]any{ + "user_id": userID, + "token_id": tokenID, + "expires": time.Now().Add(ExpiryTime), + "api_only": apiOnly, + "read_only": readOnly, + }). Suffix("RETURNING *"). ToSql() if err != nil { diff --git a/backend/routes/auth/discord.go b/backend/routes/auth/discord.go index f0f37c7..369cc38 100644 --- a/backend/routes/auth/discord.go +++ b/backend/routes/auth/discord.go @@ -117,7 +117,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error { } // save token to database - _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) if err != nil { return errors.Wrap(err, "saving token to database") } @@ -343,7 +343,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error { } // save token to database - _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) if err != nil { return errors.Wrap(err, "saving token to database") } diff --git a/backend/routes/auth/fedi_mastodon.go b/backend/routes/auth/fedi_mastodon.go index 0f40d4d..ef2a81d 100644 --- a/backend/routes/auth/fedi_mastodon.go +++ b/backend/routes/auth/fedi_mastodon.go @@ -138,7 +138,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error } // save token to database - _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) if err != nil { return errors.Wrap(err, "saving token to database") } @@ -371,7 +371,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { } // save token to database - _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) if err != nil { return errors.Wrap(err, "saving token to database") } diff --git a/backend/routes/auth/fedi_misskey.go b/backend/routes/auth/fedi_misskey.go index 07fcc46..6331def 100644 --- a/backend/routes/auth/fedi_misskey.go +++ b/backend/routes/auth/fedi_misskey.go @@ -118,7 +118,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error { } // save token to database - _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) if err != nil { return errors.Wrap(err, "saving token to database") } @@ -301,7 +301,7 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error { } // save token to database - _, err = s.DB.SaveToken(ctx, u.ID, tokenID) + _, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false) if err != nil { return errors.Wrap(err, "saving token to database") } diff --git a/backend/routes/auth/tokens.go b/backend/routes/auth/tokens.go index 4705abc..5a79042 100644 --- a/backend/routes/auth/tokens.go +++ b/backend/routes/auth/tokens.go @@ -12,16 +12,20 @@ import ( ) type getTokenResponse struct { - TokenID xid.ID `json:"id"` - Created time.Time `json:"created"` - Expires time.Time `json:"expires"` + TokenID xid.ID `json:"id"` + APIOnly bool `json:"api_only"` + ReadOnly bool `json:"read_only"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` } func dbTokenToGetResponse(t db.Token) getTokenResponse { return getTokenResponse{ - TokenID: t.TokenID, - Created: t.Created, - Expires: t.Expires, + TokenID: t.TokenID, + APIOnly: t.APIOnly, + ReadOnly: t.ReadOnly, + Created: t.Created, + Expires: t.Expires, } } @@ -47,7 +51,7 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() claims, _ := server.ClaimsFromContext(ctx) - if !claims.TokenWrite || claims.APIToken { + if claims.APIToken { return server.APIError{Code: server.ErrInvalidToken} } @@ -71,7 +75,42 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error { return nil } -func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error { - // unimplemented right now - return server.APIError{Code: server.ErrForbidden} +type createTokenResponse struct { + Token string `json:"token"` + TokenID xid.ID `json:"id"` + APIOnly bool `json:"api_only"` + ReadOnly bool `json:"read_only"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` +} + +func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + claims, _ := server.ClaimsFromContext(ctx) + + if claims.APIToken { + return server.APIError{Code: server.ErrInvalidToken} + } + + readOnly := r.FormValue("read_only") == "true" + tokenID := xid.New() + tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, false, true, !readOnly) + if err != nil { + return errors.Wrap(err, "creating token") + } + + t, err := s.DB.SaveToken(ctx, claims.UserID, tokenID, true, readOnly) + if err != nil { + return errors.Wrap(err, "saving token") + } + + render.JSON(w, r, createTokenResponse{ + Token: tokenStr, + TokenID: t.TokenID, + APIOnly: t.APIOnly, + ReadOnly: t.ReadOnly, + Created: t.Created, + Expires: t.Expires, + }) + return nil } diff --git a/frontend/src/routes/settings/+page.svelte b/frontend/src/routes/settings/+page.svelte index 5905d61..a112ef4 100644 --- a/frontend/src/routes/settings/+page.svelte +++ b/frontend/src/routes/settings/+page.svelte @@ -17,11 +17,6 @@ $: usernameValid = usernameRegex.test(username); let error: APIError | null = null; - let deleteOpen = false; - const toggleDeleteOpen = () => (deleteOpen = !deleteOpen); - let deleteUsername = ""; - let deleteError: APIError | null = null; - const changeUsername = async () => { try { const resp = await apiFetchClient("/users/@me", "PATCH", { username }); @@ -35,6 +30,11 @@ } }; + let deleteOpen = false; + const toggleDeleteOpen = () => (deleteOpen = !deleteOpen); + let deleteUsername = ""; + let deleteError: APIError | null = null; + const deleteAccount = async () => { try { await fastFetchClient("/users/@me", "DELETE"); @@ -50,6 +50,29 @@ deleteError = e as APIError; } }; + + let invalidateModalOpen = false; + const toggleInvalidateModalOpen = () => (invalidateModalOpen = !invalidateModalOpen); + let invalidateError: APIError | null = null; + + const invalidateAllTokens = async () => { + try { + await fastFetchClient("/auth/tokens", "DELETE"); + + invalidateError = null; + userStore.set(null); + localStorage.removeItem("pronouns-token"); + localStorage.removeItem("pronouns-user"); + toggleInvalidateModalOpen(); + addToast({ + header: "Invalidated tokens", + body: "Invalidated all your tokens, please log in again.", + }); + goto("/"); + } catch (e) { + invalidateError = e as APIError; + } + };

Your profile

@@ -93,6 +116,31 @@

+
+
+

Force log out

+

+ If you think one of your tokens might have been compromised, you can log out on all devices + by clicking this button. +

+

+ +

+ + Force log out + +

If you want to force log out on all devices, click the button below.

+ {#if invalidateError} + + {/if} +
+ + + + +
+
+

Account info

diff --git a/frontend/src/routes/settings/tokens/+page.svelte b/frontend/src/routes/settings/tokens/+page.svelte index a2032f6..f0f8e29 100644 --- a/frontend/src/routes/settings/tokens/+page.svelte +++ b/frontend/src/routes/settings/tokens/+page.svelte @@ -1,36 +1,89 @@ -

Tokens ({data.tokens.length})

+

+ Tokens ({data.tokens.length}) + + + + +

- - - - - - - - - {#each data.tokens as token} - - - - - - - {/each} - -
IDCreated atExpires atCurrent?
{token.id}{DateTime.fromISO(token.created).toLocal().toLocaleString(DateTime.DATETIME_MED)}{DateTime.fromISO(token.expires).toLocal().toLocaleString(DateTime.DATETIME_MED)}{#if claims["jti"] === token.id}{:else}{/if}
+{#each data.tokens as token} + + {token.id} + +
    +
  • + Created at: + {DateTime.fromISO(token.created).toLocal().toLocaleString(DateTime.DATETIME_MED)} +
  • +
  • + Expires at: + {DateTime.fromISO(token.expires).toLocal().toLocaleString(DateTime.DATETIME_MED)} +
  • +
  • + Read-only: + {token.read_only ? "yes" : "no"} +
  • +
+
+
+{:else} + You don't have any unexpired API tokens right now. +{/each} + + + New token created + +

Created a new API token! Please save it somewhere secure, as it will only be shown once.

+

{newToken}

+
+ + + +
diff --git a/frontend/src/routes/settings/tokens/+page.ts b/frontend/src/routes/settings/tokens/+page.ts index 6ad1310..5db33ca 100644 --- a/frontend/src/routes/settings/tokens/+page.ts +++ b/frontend/src/routes/settings/tokens/+page.ts @@ -2,11 +2,13 @@ import { apiFetchClient } from "$lib/api/fetch"; export const load = async () => { const tokens = await apiFetchClient("/auth/tokens"); - return { tokens }; + return { tokens: tokens.filter((token) => token.api_only) }; }; interface Token { id: string; + api_only: boolean; + read_only: boolean; created: string; expires: string; } diff --git a/scripts/migrate/011_token_info.sql b/scripts/migrate/011_token_info.sql new file mode 100644 index 0000000..c9958ac --- /dev/null +++ b/scripts/migrate/011_token_info.sql @@ -0,0 +1,6 @@ +-- +migrate Up + +-- 2023-03-30: Add token information to database + +alter table tokens add column api_only boolean not null default false; +alter table tokens add column read_only boolean not null default false;