feat: add API tokens + force log out button
This commit is contained in:
parent
9c8b6a8f91
commit
2716471fa9
9 changed files with 207 additions and 52 deletions
|
@ -14,6 +14,8 @@ type Token struct {
|
||||||
UserID xid.ID
|
UserID xid.ID
|
||||||
TokenID xid.ID
|
TokenID xid.ID
|
||||||
Invalidated bool
|
Invalidated bool
|
||||||
|
APIOnly bool `db:"api_only"`
|
||||||
|
ReadOnly bool
|
||||||
Created time.Time
|
Created time.Time
|
||||||
Expires 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
|
const ExpiryTime = 3 * 30 * 24 * time.Hour
|
||||||
|
|
||||||
// SaveToken saves a token to the database.
|
// 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").
|
sql, args, err := sq.Insert("tokens").
|
||||||
Columns("user_id", "token_id", "expires").
|
SetMap(map[string]any{
|
||||||
Values(userID, tokenID, time.Now().Add(ExpiryTime)).
|
"user_id": userID,
|
||||||
|
"token_id": tokenID,
|
||||||
|
"expires": time.Now().Add(ExpiryTime),
|
||||||
|
"api_only": apiOnly,
|
||||||
|
"read_only": readOnly,
|
||||||
|
}).
|
||||||
Suffix("RETURNING *").
|
Suffix("RETURNING *").
|
||||||
ToSql()
|
ToSql()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -117,7 +117,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// 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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
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
|
// 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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
return errors.Wrap(err, "saving token to database")
|
||||||
}
|
}
|
||||||
|
|
|
@ -138,7 +138,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// 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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
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
|
// 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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
return errors.Wrap(err, "saving token to database")
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,7 +118,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// save token to database
|
// 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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
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
|
// 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 {
|
if err != nil {
|
||||||
return errors.Wrap(err, "saving token to database")
|
return errors.Wrap(err, "saving token to database")
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,16 +12,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type getTokenResponse struct {
|
type getTokenResponse struct {
|
||||||
TokenID xid.ID `json:"id"`
|
TokenID xid.ID `json:"id"`
|
||||||
Created time.Time `json:"created"`
|
APIOnly bool `json:"api_only"`
|
||||||
Expires time.Time `json:"expires"`
|
ReadOnly bool `json:"read_only"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Expires time.Time `json:"expires"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func dbTokenToGetResponse(t db.Token) getTokenResponse {
|
func dbTokenToGetResponse(t db.Token) getTokenResponse {
|
||||||
return getTokenResponse{
|
return getTokenResponse{
|
||||||
TokenID: t.TokenID,
|
TokenID: t.TokenID,
|
||||||
Created: t.Created,
|
APIOnly: t.APIOnly,
|
||||||
Expires: t.Expires,
|
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()
|
ctx := r.Context()
|
||||||
claims, _ := server.ClaimsFromContext(ctx)
|
claims, _ := server.ClaimsFromContext(ctx)
|
||||||
|
|
||||||
if !claims.TokenWrite || claims.APIToken {
|
if claims.APIToken {
|
||||||
return server.APIError{Code: server.ErrInvalidToken}
|
return server.APIError{Code: server.ErrInvalidToken}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,7 +75,42 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
|
type createTokenResponse struct {
|
||||||
// unimplemented right now
|
Token string `json:"token"`
|
||||||
return server.APIError{Code: server.ErrForbidden}
|
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
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,11 +17,6 @@
|
||||||
$: usernameValid = usernameRegex.test(username);
|
$: usernameValid = usernameRegex.test(username);
|
||||||
let error: APIError | null = null;
|
let error: APIError | null = null;
|
||||||
|
|
||||||
let deleteOpen = false;
|
|
||||||
const toggleDeleteOpen = () => (deleteOpen = !deleteOpen);
|
|
||||||
let deleteUsername = "";
|
|
||||||
let deleteError: APIError | null = null;
|
|
||||||
|
|
||||||
const changeUsername = async () => {
|
const changeUsername = async () => {
|
||||||
try {
|
try {
|
||||||
const resp = await apiFetchClient<MeUser>("/users/@me", "PATCH", { username });
|
const resp = await apiFetchClient<MeUser>("/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 () => {
|
const deleteAccount = async () => {
|
||||||
try {
|
try {
|
||||||
await fastFetchClient("/users/@me", "DELETE");
|
await fastFetchClient("/users/@me", "DELETE");
|
||||||
|
@ -50,6 +50,29 @@
|
||||||
deleteError = e as APIError;
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Your profile</h1>
|
<h1>Your profile</h1>
|
||||||
|
@ -93,6 +116,31 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h4>Force log out</h4>
|
||||||
|
<p>
|
||||||
|
If you think one of your tokens might have been compromised, you can log out on all devices
|
||||||
|
by clicking this button.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<Button color="danger" on:click={toggleInvalidateModalOpen}>Force log out</Button>
|
||||||
|
</p>
|
||||||
|
<Modal isOpen={invalidateModalOpen} toggle={toggleInvalidateModalOpen}>
|
||||||
|
<ModalHeader toggle={toggleInvalidateModalOpen}>Force log out</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>If you want to force log out on all devices, click the button below.</p>
|
||||||
|
{#if invalidateError}
|
||||||
|
<ErrorAlert error={invalidateError} />
|
||||||
|
{/if}
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="danger" on:click={invalidateAllTokens}>Force log out</Button>
|
||||||
|
<Button color="secondary" on:click={toggleInvalidateModalOpen}>Cancel</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<h4>Account info</h4>
|
<h4>Account info</h4>
|
||||||
|
|
|
@ -1,36 +1,89 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { APIError } from "$lib/api/entities";
|
||||||
|
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { Icon, Table } from "sveltestrap";
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
} from "sveltestrap";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
interface Token {
|
||||||
|
token: string;
|
||||||
|
id: string;
|
||||||
|
api_only: boolean;
|
||||||
|
read_only: boolean;
|
||||||
|
created: string;
|
||||||
|
expires: string;
|
||||||
|
}
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
|
||||||
import { decodeJwt } from "jose";
|
let newToken: string | null = null;
|
||||||
const claims = decodeJwt(localStorage.getItem("pronouns-token")!);
|
let error: APIError | null = null;
|
||||||
|
|
||||||
|
let newTokenModalOpen = false;
|
||||||
|
const toggleNewTokenModalOpen = () => (newTokenModalOpen = !newTokenModalOpen);
|
||||||
|
|
||||||
|
const createToken = async (readOnly: boolean) => {
|
||||||
|
try {
|
||||||
|
const token = await apiFetchClient<Token>(`/auth/tokens?read_only=${readOnly}`, "POST");
|
||||||
|
newToken = token.token;
|
||||||
|
error = null;
|
||||||
|
data.tokens = [...data.tokens, token];
|
||||||
|
toggleNewTokenModalOpen();
|
||||||
|
} catch (e) {
|
||||||
|
error = e as APIError;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Tokens ({data.tokens.length})</h1>
|
<h1>
|
||||||
|
Tokens ({data.tokens.length})
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button color="success" on:click={() => createToken(false)}>New API token</Button>
|
||||||
|
<Button color="success" on:click={() => createToken(true)}>New read-only API token</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</h1>
|
||||||
|
|
||||||
<Table bordered striped hover>
|
{#each data.tokens as token}
|
||||||
<thead>
|
<Card class="my-2">
|
||||||
<th>ID</th>
|
<CardHeader>{token.id}</CardHeader>
|
||||||
<th>Created at</th>
|
<CardBody>
|
||||||
<th>Expires at</th>
|
<ul class="list-unstyled">
|
||||||
<th>Current?</th>
|
<li>
|
||||||
</thead>
|
<strong>Created at:</strong>
|
||||||
<tbody>
|
{DateTime.fromISO(token.created).toLocal().toLocaleString(DateTime.DATETIME_MED)}
|
||||||
{#each data.tokens as token}
|
</li>
|
||||||
<tr>
|
<li>
|
||||||
<td><code>{token.id}</code></td>
|
<strong>Expires at:</strong>
|
||||||
<td>{DateTime.fromISO(token.created).toLocal().toLocaleString(DateTime.DATETIME_MED)}</td>
|
{DateTime.fromISO(token.expires).toLocal().toLocaleString(DateTime.DATETIME_MED)}
|
||||||
<td>{DateTime.fromISO(token.expires).toLocal().toLocaleString(DateTime.DATETIME_MED)}</td>
|
</li>
|
||||||
<td
|
<li>
|
||||||
>{#if claims["jti"] === token.id}<Icon name="check-lg" alt="Current token" />{:else}<Icon
|
<strong>Read-only:</strong>
|
||||||
name="x-lg"
|
{token.read_only ? "yes" : "no"}
|
||||||
alt="Not current token"
|
</li>
|
||||||
/>{/if}</td
|
</ul>
|
||||||
>
|
</CardBody>
|
||||||
</tr>
|
</Card>
|
||||||
{/each}
|
{:else}
|
||||||
</tbody>
|
You don't have any unexpired API tokens right now.
|
||||||
</Table>
|
{/each}
|
||||||
|
|
||||||
|
<Modal isOpen={newTokenModalOpen}>
|
||||||
|
<ModalHeader toggle={toggleNewTokenModalOpen}>New token created</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>Created a new API token! Please save it somewhere secure, as it will only be shown once.</p>
|
||||||
|
<p><code>{newToken}</code></p>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="secondary" on:click={toggleNewTokenModalOpen}>Close</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
|
|
@ -2,11 +2,13 @@ import { apiFetchClient } from "$lib/api/fetch";
|
||||||
|
|
||||||
export const load = async () => {
|
export const load = async () => {
|
||||||
const tokens = await apiFetchClient<Token[]>("/auth/tokens");
|
const tokens = await apiFetchClient<Token[]>("/auth/tokens");
|
||||||
return { tokens };
|
return { tokens: tokens.filter((token) => token.api_only) };
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Token {
|
interface Token {
|
||||||
id: string;
|
id: string;
|
||||||
|
api_only: boolean;
|
||||||
|
read_only: boolean;
|
||||||
created: string;
|
created: string;
|
||||||
expires: string;
|
expires: string;
|
||||||
}
|
}
|
||||||
|
|
6
scripts/migrate/011_token_info.sql
Normal file
6
scripts/migrate/011_token_info.sql
Normal file
|
@ -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;
|
Loading…
Reference in a new issue