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})
+
+
+
+
+
-
-
- ID |
- Created at |
- Expires at |
- Current? |
-
-
- {#each data.tokens as token}
-
- {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}
-
-
+{#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;