diff --git a/Dockerfile b/Dockerfile index 03c5f4eb..7731d131 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,9 +41,8 @@ RUN apk add --no-cache curl su-exec COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id 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 {} \; EXPOSE 1411 diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 8e3ae971..56b746c4 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -1,11 +1,15 @@ package main import ( + "flag" + "fmt" "log" _ "time/tzdata" "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 @@ -13,7 +17,26 @@ import ( // @description.markdown 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 { log.Fatal(err.Error()) } diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index a944ea80..cbd3415d 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -27,7 +27,7 @@ func Bootstrap() error { } // Connect to the database - db := newDatabase() + db := NewDatabase() // Create all services svc, err := initServices(ctx, db, httpClient) diff --git a/backend/internal/bootstrap/db_bootstrap.go b/backend/internal/bootstrap/db_bootstrap.go index 3160d924..bab59a57 100644 --- a/backend/internal/bootstrap/db_bootstrap.go +++ b/backend/internal/bootstrap/db_bootstrap.go @@ -23,7 +23,7 @@ import ( "github.com/pocket-id/pocket-id/backend/resources" ) -func newDatabase() (db *gorm.DB) { +func NewDatabase() (db *gorm.DB) { db, err := connectDatabase() if err != nil { log.Fatalf("failed to connect to database: %v", err) diff --git a/backend/internal/cmds/one_time_access_token.go b/backend/internal/cmds/one_time_access_token.go new file mode 100644 index 00000000..cddf4d4b --- /dev/null +++ b/backend/internal/cmds/one_time_access_token.go @@ -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 ") + } + 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 +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 4a8d4bf8..5bc04bda 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -5,6 +5,7 @@ import ( "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" ) diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index b3bbf318..b86ef19d 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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) { - // If expires at is less than 15 minutes, use an 6 character token instead of 16 - tokenLength := 16 - if time.Until(expiresAt) <= 15*time.Minute { - tokenLength = 6 - } - - randomString, err := utils.GenerateRandomAlphanumericString(tokenLength) + oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt) if err != nil { return "", err } - oneTimeAccessToken := model.OneTimeAccessToken{ - UserID: userID, - ExpiresAt: datatype.DateTime(expiresAt), - Token: randomString, - } - - if err := tx.WithContext(ctx).Create(&oneTimeAccessToken).Error; err != nil { + if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil { return "", err } @@ -641,3 +629,24 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx Update("disabled", true). 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 +} diff --git a/scripts/create-one-time-access-token.sh b/scripts/create-one-time-access-token.sh deleted file mode 100644 index d41f8d7b..00000000 --- a/scripts/create-one-time-access-token.sh +++ /dev/null @@ -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 ] " - if [ "$DB_PROVIDER" == "sqlite" ]; then - echo "-d (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' }/lc/$SECRET_TOKEN" -else - echo "Error creating access token." - exit 1 -fi -echo "================================================="