mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-24 05:53:51 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee885fbff5 | ||
|
|
333a1a18d5 | ||
|
|
1ff20caa3c | ||
|
|
f6f2736bba | ||
|
|
993330d932 | ||
|
|
204313aacf | ||
|
|
0729ce9e1a | ||
|
|
2d0bd8dcbf | ||
|
|
ff75322e7d | ||
|
|
daced661c4 | ||
|
|
0716c38fb8 | ||
|
|
789d9394a5 | ||
|
|
aeda512cb7 | ||
|
|
5480ab0f18 | ||
|
|
bad901ea2b | ||
|
|
34e35193f9 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,3 +1,38 @@
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.21.0...v) (2025-01-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add warning if passkeys missing ([2d0bd8d](https://github.com/stonith404/pocket-id/commit/2d0bd8dcbfb73650b7829cb66f40decb284bd73b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow first and last name of user to be between 1 and 50 characters ([1ff20ca](https://github.com/stonith404/pocket-id/commit/1ff20caa3ccd651f9fb30f958ffb807dfbbcbd8a))
|
||||
* hash in callback url is incorrectly appended ([f6f2736](https://github.com/stonith404/pocket-id/commit/f6f2736bba65eee017f2d8cdaa70621574092869))
|
||||
* make user validation consistent between pages ([333a1a1](https://github.com/stonith404/pocket-id/commit/333a1a18d59f675111f4ed106fa5614ef563c6f4))
|
||||
* passkey can't be added if `PUBLIC_APP_URL` includes a port ([0729ce9](https://github.com/stonith404/pocket-id/commit/0729ce9e1a8dab9912900a01dcd0fbd892718a1a))
|
||||
|
||||
## [](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)
|
||||
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -17,7 +17,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
||||
|
||||
### Before you start
|
||||
|
||||
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) which requires a secure context.
|
||||
Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), meaning it must be served over HTTPS. This is necessary because Pocket ID uses the [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API).
|
||||
|
||||
### Installation with Docker (recommended)
|
||||
|
||||
@@ -78,14 +78,14 @@ Required tools:
|
||||
|
||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
||||
cd ..
|
||||
pm2 start --name pocket-id-caddy caddy -- run --config Caddyfile
|
||||
pm2 start --name pocket-id-caddy caddy -- run --config reverse-proxy/Caddyfile
|
||||
```
|
||||
|
||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||
|
||||
### Nginx Reverse Proxy
|
||||
|
||||
To use Nginx in front of Pocket ID, add the following configuration to increase the header buffer size because, as SvelteKit generates larger headers.
|
||||
To use Nginx as a reverse proxy for Pocket ID, update the configuration to increase the header buffer size. This adjustment is necessary because SvelteKit generates larger headers, which may exceed the default buffer limits.
|
||||
|
||||
```nginx
|
||||
proxy_busy_buffers_size 512k;
|
||||
@@ -95,7 +95,7 @@ proxy_buffer_size 256k;
|
||||
|
||||
## Proxy Services with Pocket ID
|
||||
|
||||
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
|
||||
As the goal of Pocket ID is to stay simple, it doesn't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/oauth2-proxy) to add authentication to your services that don't support OIDC.
|
||||
|
||||
See the [guide](docs/proxy-services.md) for more information.
|
||||
|
||||
@@ -136,7 +136,7 @@ docker compose up -d
|
||||
|
||||
# Optional: Start Caddy (You can use any other reverse proxy)
|
||||
cd ..
|
||||
pm2 start caddy --name pocket-id-caddy -- run --config Caddyfile
|
||||
pm2 start caddy --name pocket-id-caddy -- run --config reverse-proxy/Caddyfile
|
||||
```
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -13,10 +13,10 @@ type UserDto struct {
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=3,max=20"`
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
|
||||
LastName string `json:"lastName" binding:"required,min=3,max=30"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" binding:"required,min=1,max=50"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||
"log"
|
||||
"math/big"
|
||||
"os"
|
||||
@@ -96,7 +95,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||
Subject: user.ID,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Audience: jwt.ClaimStrings{utils.GetHostFromURL(common.EnvConfig.AppURL)},
|
||||
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
|
||||
},
|
||||
IsAdmin: user.IsAdmin,
|
||||
}
|
||||
@@ -125,7 +124,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaim
|
||||
return nil, errors.New("can't parse claims")
|
||||
}
|
||||
|
||||
if !slices.Contains(claims.Audience, utils.GetHostFromURL(common.EnvConfig.AppURL)) {
|
||||
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
|
||||
return nil, errors.New("audience doesn't match")
|
||||
}
|
||||
return claims, nil
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ type WebAuthnService struct {
|
||||
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
||||
webauthnConfig := &webauthn.Config{
|
||||
RPDisplayName: appConfigService.DbConfig.AppName.Value,
|
||||
RPID: utils.GetHostFromURL(common.EnvConfig.AppURL),
|
||||
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
|
||||
RPOrigins: []string{common.EnvConfig.AppURL},
|
||||
Timeouts: webauthn.TimeoutsConfig{
|
||||
Login: webauthn.TimeoutConfig{
|
||||
|
||||
@@ -29,12 +29,12 @@ func GenerateRandomAlphanumericString(length int) (string, error) {
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func GetHostFromURL(rawURL string) string {
|
||||
func GetHostnameFromURL(rawURL string) string {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsedURL.Host
|
||||
return parsedURL.Hostname()
|
||||
}
|
||||
|
||||
// StringPointer creates a string pointer from a string value
|
||||
|
||||
@@ -45,7 +45,7 @@ upstreams="http://<service-to-be-proxied>:<port>"
|
||||
|
||||
# Additional Configuration
|
||||
provider="oidc"
|
||||
scope = "openid email profile"
|
||||
scope = "openid email profile groups"
|
||||
|
||||
# If you are using a reverse proxy in front of OAuth2 Proxy
|
||||
reverse_proxy = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.20.0",
|
||||
"version": "0.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn("text-sm [&_p]:leading-relaxed", className)} {...$$restProps}>
|
||||
<slot />
|
||||
</div>
|
||||
21
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
21
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import type { HeadingLevel } from "./index.js";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLHeadingElement> & {
|
||||
level?: HeadingLevel;
|
||||
};
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let level: $$Props["level"] = "h5";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={level}
|
||||
class={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</svelte:element>
|
||||
17
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
17
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { type Variant, alertVariants } from "./index.js";
|
||||
import { cn } from "$lib/utils/style.js";
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: Variant;
|
||||
};
|
||||
|
||||
let className: $$Props["class"] = undefined;
|
||||
export let variant: $$Props["variant"] = "default";
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn(alertVariants({ variant }), className)} {...$$restProps} role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
35
frontend/src/lib/components/ui/alert/index.ts
Normal file
35
frontend/src/lib/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
import Description from './alert-description.svelte';
|
||||
import Title from './alert-title.svelte';
|
||||
import Root from './alert.svelte';
|
||||
|
||||
export const alertVariants = tv({
|
||||
base: '[&>svg]:text-foreground relative w-full rounded-lg border p-4 [&:has(svg)]:pl-11 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4',
|
||||
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background text-foreground',
|
||||
destructive:
|
||||
'border-destructive/50 text-destructive text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||
warning:
|
||||
'bg-amber-100 text-amber-900 dark:bg-amber-900 dark:text-amber-100 [&>svg]:text-amber-900 dark:[&>svg]:text-amber-100'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type Variant = VariantProps<typeof alertVariants>['variant'];
|
||||
export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
|
||||
export {
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle,
|
||||
Description,
|
||||
Root,
|
||||
Title
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
let authorizationRequired = false;
|
||||
|
||||
export let data: PageData;
|
||||
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||
|
||||
async function authorize() {
|
||||
isLoading = true;
|
||||
@@ -55,7 +55,14 @@
|
||||
isLoading = true;
|
||||
try {
|
||||
await oidService
|
||||
.authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
||||
.authorizeNewClient(
|
||||
client!.id,
|
||||
scope,
|
||||
callbackURL,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
codeChallengeMethod
|
||||
)
|
||||
.then(async ({ code, callbackURL }) => {
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
@@ -68,7 +75,11 @@
|
||||
function onSuccess(code: string, callbackURL: string) {
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
window.location.href = `${callbackURL}?code=${code}&state=${state}`;
|
||||
const redirectURL = new URL(callbackURL);
|
||||
redirectURL.searchParams.append('code', code);
|
||||
redirectURL.searchParams.append('state', state);
|
||||
|
||||
window.location.href = redirectURL.toString();
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import UserService from '$lib/services/user-service';
|
||||
@@ -8,6 +9,7 @@
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { axiosErrorToast, getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import { LucideAlertTriangle } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import AccountForm from './account-form.svelte';
|
||||
import PasskeyList from './passkey-list.svelte';
|
||||
@@ -52,6 +54,16 @@
|
||||
<title>Account Settings</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if passkeys.length == 0}
|
||||
<Alert.Root variant="warning">
|
||||
<LucideAlertTriangle class="size-4" />
|
||||
<Alert.Title>Passkey missing</Alert.Title>
|
||||
<Alert.Description
|
||||
>Please add a passkey to prevent losing access to your account.</Alert.Description
|
||||
>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
{#if $appConfigStore.allowOwnAccountEdit}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
@@ -77,7 +89,7 @@
|
||||
</Card.Header>
|
||||
{#if passkeys.length != 0}
|
||||
<Card.Content>
|
||||
<PasskeyList {passkeys} />
|
||||
<PasskeyList bind:passkeys />
|
||||
</Card.Content>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
|
||||
@@ -16,9 +16,16 @@
|
||||
let isLoading = $state(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().min(2).max(50),
|
||||
lastName: z.string().min(2).max(50),
|
||||
username: z.string().min(2).max(50),
|
||||
firstName: z.string().min(1).max(50),
|
||||
lastName: z.string().min(1).max(50),
|
||||
username: z
|
||||
.string()
|
||||
.min(2)
|
||||
.max(30)
|
||||
.regex(
|
||||
/^[a-z0-9_@.-]+$/,
|
||||
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
|
||||
),
|
||||
email: z.string().email(),
|
||||
isAdmin: z.boolean()
|
||||
});
|
||||
|
||||
@@ -9,15 +9,10 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import RenamePasskeyModal from './rename-passkey-modal.svelte';
|
||||
|
||||
let { passkeys: initialsPasskeys }: { passkeys: Passkey[] } = $props();
|
||||
let passkeys = $state<Passkey[]>(initialsPasskeys);
|
||||
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
|
||||
|
||||
const webauthnService = new WebauthnService();
|
||||
|
||||
$effect(() => {
|
||||
passkeys = initialsPasskeys;
|
||||
});
|
||||
|
||||
let passkeyToRename: Passkey | null = $state(null);
|
||||
|
||||
async function deletePasskey(passkey: Passkey) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().min(2).max(30),
|
||||
lastName: z.string().min(2).max(30),
|
||||
firstName: z.string().min(1).max(50),
|
||||
lastName: z.string().min(1).max(50),
|
||||
username: z
|
||||
.string()
|
||||
.min(2)
|
||||
|
||||
@@ -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