mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 17:23:24 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daced661c4 | ||
|
|
0716c38fb8 | ||
|
|
789d9394a5 | ||
|
|
aeda512cb7 | ||
|
|
5480ab0f18 | ||
|
|
bad901ea2b | ||
|
|
34e35193f9 |
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,3 +1,23 @@
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.20.1...v) (2024-12-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improve error state design for login page ([0716c38](https://github.com/stonith404/pocket-id/commit/0716c38fb8ce7fa719c7fe0df750bdb213786c21))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* OIDC client logo gets removed if other properties get updated ([789d939](https://github.com/stonith404/pocket-id/commit/789d9394a533831e7e2fb8dc3f6b338787336ad8))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.20.0...v) (2024-12-13)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* `create-one-time-access-token.sh` script not compatible with postgres ([34e3519](https://github.com/stonith404/pocket-id/commit/34e35193f9f3813f6248e60f15080d753e8da7ae))
|
||||
* wrong date time datatype used for read operations with Postgres ([bad901e](https://github.com/stonith404/pocket-id/commit/bad901ea2b661aadd286e5e4bed317e73bd8a70d))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
"log"
|
||||
"time"
|
||||
@@ -29,22 +30,22 @@ type Jobs struct {
|
||||
|
||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||
func (j *Jobs) clearWebauthnSessions() error {
|
||||
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", time.Now().Unix()).Error
|
||||
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||
}
|
||||
|
||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||
func (j *Jobs) clearOneTimeAccessTokens() error {
|
||||
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", time.Now().Unix()).Error
|
||||
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||
}
|
||||
|
||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
||||
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", time.Now().Unix()).Error
|
||||
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
||||
}
|
||||
|
||||
// ClearAuditLogs deletes audit logs older than 90 days
|
||||
func (j *Jobs) clearAuditLogs() error {
|
||||
return j.db.Delete(&model.AuditLog{}, "created_at < ?", time.Now().AddDate(0, 0, -90).Unix()).Error
|
||||
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
|
||||
}
|
||||
|
||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateTime custom type for time.Time to store date as unix timestamp in the database
|
||||
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
|
||||
type DateTime time.Time
|
||||
|
||||
func (date *DateTime) Scan(value interface{}) (err error) {
|
||||
|
||||
@@ -57,6 +57,29 @@ func (s *TestService) SeedDatabase() error {
|
||||
}
|
||||
}
|
||||
|
||||
oneTimeAccessTokens := []model.OneTimeAccessToken{{
|
||||
Base: model.Base{
|
||||
ID: "bf877753-4ea4-4c9c-bbbd-e198bb201cb8",
|
||||
},
|
||||
Token: "HPe6k6uiDRRVuAQV",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "d3afae24-fe2d-4a98-abec-cf0b8525096a",
|
||||
},
|
||||
Token: "YCGDtftvsvYWiXd0",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Second)), // expired
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
}
|
||||
for _, token := range oneTimeAccessTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
userGroups := []model.UserGroup{
|
||||
{
|
||||
Base: model.Base{
|
||||
|
||||
@@ -112,7 +112,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
|
||||
|
||||
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
|
||||
var oneTimeAccessToken model.OneTimeAccessToken
|
||||
if err := s.db.Where("token = ? AND expires_at > ?", token, time.Now().Unix()).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||
if err := s.db.Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").First(&oneTimeAccessToken).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
|
||||
@@ -10,7 +10,7 @@ export type OidcClient = {
|
||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||
|
||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||
logo: File | null;
|
||||
logo: File | null | undefined;
|
||||
};
|
||||
|
||||
export type AuthorizeResponse = {
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import Logo from '$lib/components/logo.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorIndicator from './components/login-logo-error-indicator.svelte';
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state(undefined);
|
||||
|
||||
async function authenticate() {
|
||||
error = undefined;
|
||||
isLoading = true;
|
||||
try {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
@@ -23,7 +25,7 @@
|
||||
userStore.setUser(user);
|
||||
goto('/settings');
|
||||
} catch (e) {
|
||||
toast.error(getWebauthnErrorMessage(e));
|
||||
error = getWebauthnErrorMessage(e);
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
@@ -35,15 +37,21 @@
|
||||
|
||||
<SignInWrapper>
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-muted rounded-2xl p-3">
|
||||
<Logo class="h-10 w-10" />
|
||||
</div>
|
||||
<LoginLogoErrorIndicator error={!!error} />
|
||||
</div>
|
||||
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
|
||||
Sign in to {$appConfigStore.appName}
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-2">
|
||||
Authenticate yourself with your passkey to access the admin panel
|
||||
</p>
|
||||
<Button class="mt-5" {isLoading} on:click={authenticate}>Authenticate</Button>
|
||||
{#if error}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
{error}. Please try to sign in again.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground mt-2" in:fade>
|
||||
Authenticate yourself with your passkey to access the admin panel.
|
||||
</p>
|
||||
{/if}
|
||||
<Button class="mt-10" {isLoading} on:click={authenticate}
|
||||
>{error ? 'Try again' : 'Authenticate'}</Button
|
||||
>
|
||||
</SignInWrapper>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import Logo from '$lib/components/logo.svelte';
|
||||
import CrossAnimated from '$lib/icons/cross-animated.svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const {
|
||||
error
|
||||
}: {
|
||||
error: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-2xl p-3 transition-[background-color] duration-300
|
||||
{error ? 'bg-red-200' : 'bg-muted'}"
|
||||
>
|
||||
{#if error}
|
||||
<div class="flex h-10 w-10 items-center justify-center">
|
||||
<CrossAnimated class="h-5 w-5" />
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade={{ duration: 300 }}>
|
||||
<Logo class="h-10 w-10" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -33,7 +33,10 @@
|
||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||
let success = true;
|
||||
const dataPromise = oidcService.updateClient(client.id, updatedClient);
|
||||
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
|
||||
const imagePromise =
|
||||
updatedClient.logo !== undefined
|
||||
? oidcService.updateClientLogo(client, updatedClient.logo)
|
||||
: Promise.resolve();
|
||||
|
||||
client.isPublic = updatedClient.isPublic;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let logo = $state<File | null>(null);
|
||||
let logo = $state<File | null | undefined>();
|
||||
let logoDataURL: string | null = $state(
|
||||
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
|
||||
);
|
||||
@@ -108,7 +108,7 @@
|
||||
onchange={onLogoChange}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
|
||||
{logoDataURL ? 'Change Logo' : 'Upload Logo'}
|
||||
</Button>
|
||||
</FileInput>
|
||||
{#if logoDataURL}
|
||||
|
||||
@@ -55,3 +55,8 @@ export const userGroups = {
|
||||
name: 'human_resources'
|
||||
}
|
||||
};
|
||||
|
||||
export const oneTimeAccessTokens = [
|
||||
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
|
||||
{ token: 'YCGDtftvsvYWiXd0', expired: true }
|
||||
];
|
||||
|
||||
21
frontend/tests/one-time-access-token.spec.ts
Normal file
21
frontend/tests/one-time-access-token.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import test, { expect } from '@playwright/test';
|
||||
import { oneTimeAccessTokens } from './data';
|
||||
|
||||
// Disable authentication for these tests
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('Sign in with one time access token', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => !t.expired)[0];
|
||||
await page.goto(`/login/${token.token}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.waitForURL('/settings/account');
|
||||
});
|
||||
|
||||
test('Sign in with expired one time access token fails', async ({ page }) => {
|
||||
const token = oneTimeAccessTokens.filter((t) => t.expired)[0];
|
||||
await page.goto(`/login/${token.token}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Token is invalid or expired');
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
# Default database path
|
||||
DB_PATH="./backend/data/pocket-id.db"
|
||||
DB_PROVIDER="${DB_PROVIDER:=sqlite}"
|
||||
USER_IDENTIFIER="$1"
|
||||
|
||||
# Parse command-line arguments for the -d flag (database path)
|
||||
while getopts ":d:" opt; do
|
||||
@@ -19,12 +20,12 @@ shift $((OPTIND - 1))
|
||||
# Ensure username or email is provided as a parameter
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: $0 [-d <database_path>] <username or email>"
|
||||
echo " -d Specify the database path (optional, defaults to ./backend/data/pocket-id.db)"
|
||||
if [ "$DB_PROVIDER" == "sqlite" ]; then
|
||||
echo "-d <database_path> (optional): Path to the SQLite database file. Default: $DB_PATH"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USER_IDENTIFIER="$1"
|
||||
|
||||
# Check and try to install the required commands
|
||||
check_and_install() {
|
||||
local cmd=$1
|
||||
@@ -41,8 +42,12 @@ check_and_install() {
|
||||
fi
|
||||
}
|
||||
|
||||
check_and_install sqlite3 sqlite
|
||||
check_and_install uuidgen uuidgen
|
||||
if [ "$DB_PROVIDER" == "postgres" ]; then
|
||||
check_and_install psql postgresql-client
|
||||
elif [ "$DB_PROVIDER" == "sqlite" ]; then
|
||||
check_and_install sqlite3 sqlite
|
||||
fi
|
||||
|
||||
# Generate a 16-character alphanumeric secret token
|
||||
SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)
|
||||
@@ -51,21 +56,47 @@ SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)
|
||||
CREATED_AT=$(date +%s)
|
||||
EXPIRES_AT=$((CREATED_AT + 3600))
|
||||
|
||||
# Retrieve user_id from the users table based on username or email
|
||||
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
|
||||
# Retrieve user_id based on username or email and insert token
|
||||
if [ "$DB_PROVIDER" == "postgres" ]; then
|
||||
if [ -z "$POSTGRES_CONNECTION_STRING" ]; then
|
||||
echo "Error: POSTGRES_CONNECTION_STRING must be set when using PostgreSQL."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if user exists
|
||||
if [ -z "$USER_ID" ]; then
|
||||
echo "User not found for username/email: $USER_IDENTIFIER"
|
||||
exit 1
|
||||
fi
|
||||
# Retrieve user_id
|
||||
USER_ID=$(psql "$POSTGRES_CONNECTION_STRING" -Atc "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
|
||||
|
||||
# Insert the one-time token into the one_time_access_tokens table
|
||||
sqlite3 "$DB_PATH" <<EOF
|
||||
if [ -z "$USER_ID" ]; then
|
||||
echo "User not found for username/email: $USER_IDENTIFIER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Insert the one-time token
|
||||
psql "$POSTGRES_CONNECTION_STRING" <<EOF
|
||||
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
|
||||
VALUES ('$(uuidgen)', to_timestamp('$CREATED_AT'), '$SECRET_TOKEN', to_timestamp('$EXPIRES_AT'), '$USER_ID');
|
||||
EOF
|
||||
|
||||
elif [ "$DB_PROVIDER" == "sqlite" ]; then
|
||||
# Retrieve user_id
|
||||
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
|
||||
|
||||
if [ -z "$USER_ID" ]; then
|
||||
echo "User not found for username/email: $USER_IDENTIFIER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Insert the one-time token
|
||||
sqlite3 "$DB_PATH" <<EOF
|
||||
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
|
||||
VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID');
|
||||
EOF
|
||||
else
|
||||
echo "Error: Invalid DB_PROVIDER. Must be 'postgres' or 'sqlite'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "================================================="
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"."
|
||||
echo "Use the following URL to sign in once: ${PUBLIC_APP_URL:=https://<your-pocket-id-domain>}/login/$SECRET_TOKEN"
|
||||
@@ -73,3 +104,4 @@ else
|
||||
echo "Error creating access token."
|
||||
exit 1
|
||||
fi
|
||||
echo "================================================="
|
||||
|
||||
Reference in New Issue
Block a user