Compare commits

..

7 Commits

Author SHA1 Message Date
Elias Schneider
daced661c4 release: 0.21.0 2024-12-17 19:58:55 +01:00
Elias Schneider
0716c38fb8 feat: improve error state design for login page 2024-12-17 19:36:47 +01:00
Elias Schneider
789d9394a5 fix: OIDC client logo gets removed if other properties get updated 2024-12-17 19:00:33 +01:00
Elias Schneider
aeda512cb7 release: 0.20.1 2024-12-13 09:12:37 +01:00
Elias Schneider
5480ab0f18 tests: add e2e test for one time access tokens 2024-12-13 09:03:52 +01:00
Elias Schneider
bad901ea2b fix: wrong date time datatype used for read operations with Postgres 2024-12-13 08:43:46 +01:00
Elias Schneider
34e35193f9 fix: create-one-time-access-token.sh script not compatible with postgres 2024-12-12 23:03:07 +01:00
15 changed files with 176 additions and 37 deletions

View File

@@ -1 +1 @@
0.20.0 0.21.0

View File

@@ -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) ## [](https://github.com/stonith404/pocket-id/compare/v0.19.0...v) (2024-12-12)

View File

@@ -4,6 +4,7 @@ import (
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
"log" "log"
"time" "time"
@@ -29,22 +30,22 @@ type Jobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired // ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error { 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 // ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error { 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 // ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error { 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 // ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error { 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) { func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {

View File

@@ -6,7 +6,7 @@ import (
"time" "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 type DateTime time.Time
func (date *DateTime) Scan(value interface{}) (err error) { func (date *DateTime) Scan(value interface{}) (err error) {

View File

@@ -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{ userGroups := []model.UserGroup{
{ {
Base: model.Base{ Base: model.Base{

View File

@@ -112,7 +112,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) { func (s *UserService) ExchangeOneTimeAccessToken(token string) (model.User, string, error) {
var oneTimeAccessToken model.OneTimeAccessToken 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) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{} return model.User{}, "", &common.TokenInvalidOrExpiredError{}
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.20.0", "version": "0.21.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000",

View File

@@ -10,7 +10,7 @@ export type OidcClient = {
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>; export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & { export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null; logo: File | null | undefined;
}; };
export type AuthorizeResponse = { export type AuthorizeResponse = {

View File

@@ -1,19 +1,21 @@
<script> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; 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(); const webauthnService = new WebAuthnService();
let isLoading = $state(false); let isLoading = $state(false);
let error: string | undefined = $state(undefined);
async function authenticate() { async function authenticate() {
error = undefined;
isLoading = true; isLoading = true;
try { try {
const loginOptions = await webauthnService.getLoginOptions(); const loginOptions = await webauthnService.getLoginOptions();
@@ -23,7 +25,7 @@
userStore.setUser(user); userStore.setUser(user);
goto('/settings'); goto('/settings');
} catch (e) { } catch (e) {
toast.error(getWebauthnErrorMessage(e)); error = getWebauthnErrorMessage(e);
} }
isLoading = false; isLoading = false;
} }
@@ -35,15 +37,21 @@
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3"> <LoginLogoErrorIndicator error={!!error} />
<Logo class="h-10 w-10" />
</div>
</div> </div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl"> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName} Sign in to {$appConfigStore.appName}
</h1> </h1>
<p class="text-muted-foreground mt-2"> {#if error}
Authenticate yourself with your passkey to access the admin panel <p class="text-muted-foreground mt-2" in:fade>
{error}. Please try to sign in again.
</p> </p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Authenticate</Button> {: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> </SignInWrapper>

View File

@@ -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>

View File

@@ -33,7 +33,10 @@
async function updateClient(updatedClient: OidcClientCreateWithLogo) { async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true; let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient); 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; client.isPublic = updatedClient.isPublic;

View File

@@ -22,7 +22,7 @@
} = $props(); } = $props();
let isLoading = $state(false); let isLoading = $state(false);
let logo = $state<File | null>(null); let logo = $state<File | null | undefined>();
let logoDataURL: string | null = $state( let logoDataURL: string | null = $state(
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
); );
@@ -108,7 +108,7 @@
onchange={onLogoChange} onchange={onLogoChange}
> >
<Button variant="secondary"> <Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'} {logoDataURL ? 'Change Logo' : 'Upload Logo'}
</Button> </Button>
</FileInput> </FileInput>
{#if logoDataURL} {#if logoDataURL}

View File

@@ -55,3 +55,8 @@ export const userGroups = {
name: 'human_resources' name: 'human_resources'
} }
}; };
export const oneTimeAccessTokens = [
{ token: 'HPe6k6uiDRRVuAQV', expired: false },
{ token: 'YCGDtftvsvYWiXd0', expired: true }
];

View 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');
});

View File

@@ -1,5 +1,6 @@
# Default database path
DB_PATH="./backend/data/pocket-id.db" 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) # Parse command-line arguments for the -d flag (database path)
while getopts ":d:" opt; do while getopts ":d:" opt; do
@@ -19,12 +20,12 @@ shift $((OPTIND - 1))
# Ensure username or email is provided as a parameter # Ensure username or email is provided as a parameter
if [ -z "$1" ]; then if [ -z "$1" ]; then
echo "Usage: $0 [-d <database_path>] <username or email>" 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 exit 1
fi fi
USER_IDENTIFIER="$1"
# Check and try to install the required commands # Check and try to install the required commands
check_and_install() { check_and_install() {
local cmd=$1 local cmd=$1
@@ -41,8 +42,12 @@ check_and_install() {
fi fi
} }
check_and_install sqlite3 sqlite
check_and_install uuidgen uuidgen 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 # Generate a 16-character alphanumeric secret token
SECRET_TOKEN=$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16) 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) CREATED_AT=$(date +%s)
EXPIRES_AT=$((CREATED_AT + 3600)) EXPIRES_AT=$((CREATED_AT + 3600))
# Retrieve user_id from the users table based on username or email # Retrieve user_id based on username or email and insert token
USER_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';") 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
# Retrieve user_id
USER_ID=$(psql "$POSTGRES_CONNECTION_STRING" -Atc "SELECT id FROM users WHERE username='$USER_IDENTIFIER' OR email='$USER_IDENTIFIER';")
# Check if user exists
if [ -z "$USER_ID" ]; then if [ -z "$USER_ID" ]; then
echo "User not found for username/email: $USER_IDENTIFIER" echo "User not found for username/email: $USER_IDENTIFIER"
exit 1 exit 1
fi fi
# Insert the one-time token into the one_time_access_tokens table # 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 sqlite3 "$DB_PATH" <<EOF
INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id) INSERT INTO one_time_access_tokens (id, created_at, token, expires_at, user_id)
VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID'); VALUES ('$(uuidgen)', '$CREATED_AT', '$SECRET_TOKEN', '$EXPIRES_AT', '$USER_ID');
EOF EOF
else
echo "Error: Invalid DB_PROVIDER. Must be 'postgres' or 'sqlite'."
exit 1
fi
echo "================================================="
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo "A one-time access token valid for 1 hour has been created for \"$USER_IDENTIFIER\"." 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" 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." echo "Error creating access token."
exit 1 exit 1
fi fi
echo "================================================="