refactor: replace create-one-time-access-token script with in-app functionality (#540)

This commit is contained in:
Alessandro (Ale) Segala
2025-05-18 04:22:40 -07:00
committed by Elias Schneider
parent 35b227cd17
commit cb2a9f9f7d
8 changed files with 132 additions and 136 deletions

View File

@@ -41,9 +41,8 @@ RUN apk add --no-cache curl su-exec
COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id
COPY ./scripts/docker /app/docker COPY ./scripts/docker /app/docker
COPY ./scripts/create-one-time-access-token.sh /app/
RUN chmod +x /app/pocket-id /app/create-one-time-access-token.sh && \ RUN chmod +x /app/pocket-id && \
find /app/docker -name "*.sh" -exec chmod +x {} \; find /app/docker -name "*.sh" -exec chmod +x {} \;
EXPOSE 1411 EXPOSE 1411

View File

@@ -1,11 +1,15 @@
package main package main
import ( import (
"flag"
"fmt"
"log" "log"
_ "time/tzdata" _ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap" "github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/cmds"
"github.com/pocket-id/pocket-id/backend/internal/common"
) )
// @title Pocket ID API // @title Pocket ID API
@@ -13,7 +17,26 @@ import (
// @description.markdown // @description.markdown
func main() { func main() {
err := bootstrap.Bootstrap() // Get the command
// By default, this starts the server
var cmd string
flag.Parse()
args := flag.Args()
if len(args) > 0 {
cmd = args[0]
}
var err error
switch cmd {
case "version":
fmt.Println("pocket-id " + common.Version)
case "one-time-access-token":
err = cmds.OneTimeAccessToken(args)
default:
// Start the server
err = bootstrap.Bootstrap()
}
if err != nil { if err != nil {
log.Fatal(err.Error()) log.Fatal(err.Error())
} }

View File

@@ -27,7 +27,7 @@ func Bootstrap() error {
} }
// Connect to the database // Connect to the database
db := newDatabase() db := NewDatabase()
// Create all services // Create all services
svc, err := initServices(ctx, db, httpClient) svc, err := initServices(ctx, db, httpClient)

View File

@@ -23,7 +23,7 @@ import (
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
func newDatabase() (db *gorm.DB) { func NewDatabase() (db *gorm.DB) {
db, err := connectDatabase() db, err := connectDatabase()
if err != nil { if err != nil {
log.Fatalf("failed to connect to database: %v", err) log.Fatalf("failed to connect to database: %v", err)

View File

@@ -0,0 +1,81 @@
package cmds
import (
"context"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
// OneTimeAccessToken creates a one-time access token for the given user
// Args must contain the username or email of the user
func OneTimeAccessToken(args []string) error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
// Get the username or email of the user
// Note length is 2 because the first argument is always the command (one-time-access-token)
if len(args) != 2 {
return errors.New("missing username or email of user; usage: one-time-access-token <username or email>")
}
userArg := args[1]
// Connect to the database
db := bootstrap.NewDatabase()
// Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken
err := db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
txErr := tx.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
if errors.Is(txErr, gorm.ErrRecordNotFound) {
return errors.New("user not found")
} else if txErr != nil {
return fmt.Errorf("failed to query for user: %w", txErr)
} else if user.ID == "" {
return errors.New("invalid user loaded: ID is empty")
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}
queryCtx, queryCancel = context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
txErr = tx.
WithContext(queryCtx).
Create(oneTimeAccessToken).
Error
if txErr != nil {
return fmt.Errorf("failed to save access token: %w", txErr)
}
return nil
})
if err != nil {
return err
}
// Print the result
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
return nil
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )

View File

@@ -420,24 +420,12 @@ func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID strin
} }
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) { func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) {
// If expires at is less than 15 minutes, use an 6 character token instead of 16 oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt)
tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute {
tokenLength = 6
}
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
if err != nil { if err != nil {
return "", err return "", err
} }
oneTimeAccessToken := model.OneTimeAccessToken{ if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil {
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
if err := tx.WithContext(ctx).Create(&oneTimeAccessToken).Error; err != nil {
return "", err return "", err
} }
@@ -641,3 +629,24 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
Update("disabled", true). Update("disabled", true).
Error Error
} }
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute {
tokenLength = 6
}
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
if err != nil {
return nil, err
}
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt),
Token: randomString,
}
return o, nil
}

View File

@@ -1,117 +0,0 @@
#!/bin/sh
# TODO: Should parse DB_CONNECTION_STRING
DB_PATH="/app/data/pocket-id.db"
DB_PROVIDER="${DB_PROVIDER:=sqlite}"
# Parse command-line arguments for the -d flag (database path)
while getopts ":d:" opt; do
case $opt in
d)
DB_PATH="$OPTARG"
;;
\?)
echo "Invalid option -$OPTARG" >&2
exit 1
;;
esac
done
# Shift past the processed options
shift $((OPTIND - 1))
# Ensure username or email is provided as a parameter
USER_IDENTIFIER="$1"
if [ -z "$USER_IDENTIFIER" ]; then
echo "Usage: $0 [-d <database_path>] <username or email>"
if [ "$DB_PROVIDER" == "sqlite" ]; then
echo "-d <database_path> (optional): Path to the SQLite database file. Default: $DB_PATH"
fi
exit 1
fi
# Check and try to install the required commands
check_and_install() {
local cmd=$1
local pkg=$2
if
! command -v "$cmd" &
>/dev/null
then
if
command -v apk &
>/dev/null
then
echo "$cmd not found. Installing..."
apk add "$pkg" --no-cache
else
echo "$cmd is not installed, please install it manually."
exit 1
fi
fi
}
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)
# Get the current Unix timestamp for creation and expiration (1 hour from now)
CREATED_AT=$(date +%s)
EXPIRES_AT=$((CREATED_AT + 3600))
# 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
# Retrieve user_id
USER_ID=$(psql "$POSTGRES_CONNECTION_STRING" -Atc "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
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: ${APP_URL:=https://<your-pocket-id-domain>}/lc/$SECRET_TOKEN"
else
echo "Error creating access token."
exit 1
fi
echo "================================================="