mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-18 01:11:26 +03:00
refactor: replace create-one-time-access-token script with in-app functionality (#540)
This commit is contained in:
committed by
Elias Schneider
parent
35b227cd17
commit
cb2a9f9f7d
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
81
backend/internal/cmds/one_time_access_token.go
Normal file
81
backend/internal/cmds/one_time_access_token.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 "================================================="
|
|
||||||
Reference in New Issue
Block a user