Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions backend/app/api/cli_reset_password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package main

import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"os"
"strings"

"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"github.com/pressly/goose/v3"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/migrations"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
)

// runResetPasswordCLI handles `homebox reset-password --email=...`. It mints a
// one-time reset link and prints it to stdout. This is the escape hatch for
// installations without SMTP, or for debugging the password matching path
// itself — the cases where the email-based flow can't help.
//
// Returns true when it consumed the command (and the caller should exit), so
// `homebox` with no subcommand still falls through to the server.
func runResetPasswordCLI(args []string) (handled bool, exitCode int) {
if len(args) < 2 || args[1] != "reset-password" {
return false, 0
}

fs := flag.NewFlagSet("reset-password", flag.ContinueOnError)
email := fs.String("email", "", "Email address of the account to reset")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: homebox reset-password --email=<address>")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Generates a one-time password reset link for the given account and")
fmt.Fprintln(os.Stderr, "prints it to stdout. The link expires in one hour and can be used once.")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "All HBOX_* environment variables (database, hostname) are honored.")
fs.PrintDefaults()
}
if err := fs.Parse(args[2:]); err != nil {
return true, 2
}
trimmedEmail := strings.TrimSpace(*email)
if trimmedEmail == "" {
fs.Usage()
return true, 2
}

cfg, err := config.New(build(), "Homebox inventory management system")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
return true, 1
}

link, err := generateResetLinkOffline(cfg, trimmedEmail)
if err != nil {
if ent.IsNotFound(err) {
fmt.Fprintf(os.Stderr, "no account found for %s\n", trimmedEmail)
return true, 1
}
fmt.Fprintf(os.Stderr, "failed to generate reset link: %v\n", err)
return true, 1
}

fmt.Println(link)
return true, 0
}

// generateResetLinkOffline opens the database, runs migrations if needed, and
// mints a token without starting the HTTP server. The returned link uses
// HBOX_OPTIONS_HOSTNAME if set; otherwise it's emitted as a path the operator
// can append to whatever URL their instance is reachable at.
func generateResetLinkOffline(cfg *config.Config, email string) (string, error) {
databaseURL, err := setupDatabaseURL(cfg)
if err != nil {
return "", fmt.Errorf("setup database url: %w", err)
}

driver := strings.ToLower(cfg.Database.Driver)
var driverName, dialectName string
switch driver {
case config.DriverPostgres:
driverName = "pgx"
dialectName = dialect.Postgres
case config.DriverSqlite3, "sqlite":
driverName = "sqlite3"
dialectName = dialect.SQLite
default:
return "", fmt.Errorf("unsupported driver: %s", driver)
}

db, err := sql.Open(driverName, databaseURL)
if err != nil {
return "", fmt.Errorf("open db: %w", err)
}
defer func() { _ = db.Close() }()

drv := entsql.OpenDB(dialectName, db)
c := ent.NewClient(ent.Driver(drv))
defer func() { _ = c.Close() }()

migrationsFs, err := migrations.Migrations(driver)
if err != nil {
return "", fmt.Errorf("load migrations: %w", err)
}
goose.SetBaseFS(migrationsFs)
if err := goose.SetDialect(driver); err != nil {
return "", fmt.Errorf("set dialect: %w", err)
}
if err := goose.Up(c.Sql(), driver); err != nil {
return "", fmt.Errorf("apply migrations: %w", err)
}

bus := eventbus.New()
repos := repo.New(c, bus, cfg.Storage, cfg.Database.PubSubConnString, cfg.Thumbnail)
svc := services.New(repos)

baseURL := strings.TrimSuffix(cfg.Options.Hostname, "/")
if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
// Hostname without a scheme: assume https. The CLI has no request to
// inspect for r.TLS, and https is the safer default to print.
baseURL = "https://" + baseURL
}

link, err := svc.User.GenerateResetLink(context.Background(), email, baseURL)
if err != nil {
if errors.Is(err, services.ErrorMailerNotConfigured) {
// Should never happen here; GenerateResetLink doesn't touch the
// mailer. Defensive in case future refactors reorder things.
return "", err
}
return "", err
}
return link, nil
}
19 changes: 19 additions & 0 deletions backend/app/api/handlers/v1/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ func getScheme(r *http.Request, trustProxy bool) string {
return "http"
}

// SecureBaseURL returns a base URL safe to embed in security-sensitive emails
// (password reset, etc.). Unlike GetHBURL it deliberately omits the Referer
// fallback, since Referer is unauthenticated client input — an attacker who
// can reach /forgot-password could otherwise poison the link in the victim's
// reset email and phish the new password. X-Forwarded-Host is honored only
// when the operator has opted into TrustProxy. Returns "" when no trusted
// source is available; callers must refuse the operation in that case.
func SecureBaseURL(r *http.Request, options *config.Options) string {
if options.Hostname != "" {
return ensureScheme(options.Hostname, r, options.TrustProxy)
}
if options.TrustProxy {
if xfHost := r.Header.Get("X-Forwarded-Host"); xfHost != "" {
return getScheme(r, options.TrustProxy) + "://" + xfHost
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}
return ""
}

// stripPathFromURL removes the path from a URL.
// ex. https://example.com/tools -> https://example.com
func stripPathFromURL(rawURL string) string {
Expand Down
144 changes: 144 additions & 0 deletions backend/app/api/handlers/v1/v1_ctrl_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,150 @@ func (ctrl *V1Controller) HandleAuthLogin(ps ...AuthProvider) errchain.HandlerFu
}
}

type (
ForgotPasswordRequest struct {
Email string `json:"email" example:"user@example.com"`
}

ResetPasswordRequest struct {
Token string `json:"token"`
NewPassword string `json:"password"`
}
)

// HandleForgotPassword godoc
//
// @Summary Request Password Reset
// @Description Sends a password reset email if the address is associated with a local account.
// @Description Always returns 204 on success to avoid leaking whether the email is registered.
// @Tags Authentication
// @Accept application/json
// @Produce json
// @Param payload body ForgotPasswordRequest true "Email"
// @Success 204
// @Failure 400 {string} string "missing or invalid request body"
// @Failure 403 {string} string "demo mode is enabled or local login is disabled"
// @Failure 500 {string} string "internal error while processing the request"
// @Failure 503 {string} string "SMTP is not configured, or HBOX_OPTIONS_HOSTNAME is unset so a safe reset URL cannot be built"
// @Router /v1/users/forgot-password [POST]
Comment thread
tankerkiller125 marked this conversation as resolved.
func (ctrl *V1Controller) HandleForgotPassword() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
spanCtx, span := startEntityCtrlSpan(r.Context(), "controller.V1.HandleForgotPassword")
defer span.End()

if ctrl.isDemo {
span.SetAttributes(attribute.String("forgot.outcome", "demo_blocked"))
return validate.NewRequestError(nil, http.StatusForbidden)
}

if !ctrl.config.Options.AllowLocalLogin {
span.SetAttributes(attribute.String("forgot.outcome", "local_login_disabled"))
return validate.NewRequestError(errors.New("local login is not enabled"), http.StatusForbidden)
}

var body ForgotPasswordRequest
if err := server.Decode(r, &body); err != nil {
span.SetAttributes(attribute.String("forgot.outcome", "decode_failed"))
return validate.NewRequestError(err, http.StatusBadRequest)
}
body.Email = strings.TrimSpace(body.Email)
span.SetAttributes(attribute.Int("user.email.length", len(body.Email)))
if body.Email == "" {
span.SetAttributes(attribute.String("forgot.outcome", "missing_email"))
return validate.NewRequestError(errors.New("email is required"), http.StatusBadRequest)
}

if !ctrl.svc.User.MailerReady() {
// Surface the misconfiguration explicitly rather than silently
// swallowing it: an admin staring at "we sent an email" with
// nothing arriving is much harder to debug than a clear 503.
span.SetAttributes(attribute.String("forgot.outcome", "mailer_not_configured"))
return validate.NewRequestError(
errors.New("password reset by email is not available — SMTP is not configured on this server"),
http.StatusServiceUnavailable,
)
}

// SecureBaseURL refuses Referer-based fallback so an attacker can't
// poison the link in the victim's email by sending a forged Referer.
baseURL := SecureBaseURL(r, &ctrl.config.Options)
if baseURL == "" {
span.SetAttributes(attribute.String("forgot.outcome", "no_safe_base_url"))
return validate.NewRequestError(
errors.New("password reset by email is not available — set HBOX_OPTIONS_HOSTNAME so the reset link can be constructed safely"),
http.StatusServiceUnavailable,
)
}

if err := ctrl.svc.User.RequestPasswordReset(spanCtx, body.Email, baseURL); err != nil {
recordCtrlSpanError(span, err)
span.SetAttributes(attribute.String("forgot.outcome", "service_failed"))
log.Err(err).Msg("password reset request failed")
// Don't leak the underlying error to the client; respond with 500
// but no details.
return validate.NewRequestError(errors.New("internal error"), http.StatusInternalServerError)
}

span.SetAttributes(attribute.String("forgot.outcome", "ok"))
return server.JSON(w, http.StatusNoContent, nil)
}
}

// HandleResetPassword godoc
//
// @Summary Reset Password
// @Description Consumes a single-use reset token and changes the user's password.
// @Description On success, all existing sessions for the user are revoked.
// @Tags Authentication
// @Accept application/json
// @Produce json
// @Param payload body ResetPasswordRequest true "Token + new password"
// @Success 204
// @Failure 400 {string} string "invalid request body, invalid token, expired token, or already-used token"
// @Failure 403 {string} string "demo mode is enabled or local login is disabled"
// @Failure 500 {string} string "internal error while processing the request"
// @Router /v1/users/reset-password [POST]
func (ctrl *V1Controller) HandleResetPassword() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
spanCtx, span := startEntityCtrlSpan(r.Context(), "controller.V1.HandleResetPassword")
defer span.End()

if ctrl.isDemo {
span.SetAttributes(attribute.String("reset.outcome", "demo_blocked"))
return validate.NewRequestError(nil, http.StatusForbidden)
}

if !ctrl.config.Options.AllowLocalLogin {
span.SetAttributes(attribute.String("reset.outcome", "local_login_disabled"))
return validate.NewRequestError(errors.New("local login is not enabled"), http.StatusForbidden)
}

var body ResetPasswordRequest
if err := server.Decode(r, &body); err != nil {
span.SetAttributes(attribute.String("reset.outcome", "decode_failed"))
return validate.NewRequestError(err, http.StatusBadRequest)
}
span.SetAttributes(
attribute.Int("token.length", len(body.Token)),
attribute.Int("password.new.length", len(body.NewPassword)),
)

if err := ctrl.svc.User.ResetPassword(spanCtx, body.Token, body.NewPassword); err != nil {
if errors.Is(err, services.ErrorPasswordResetInvalid) {
span.SetAttributes(attribute.String("reset.outcome", "token_invalid"))
return validate.NewRequestError(err, http.StatusBadRequest)
}
recordCtrlSpanError(span, err)
span.SetAttributes(attribute.String("reset.outcome", "service_failed"))
log.Err(err).Msg("password reset failed")
return validate.NewRequestError(errors.New("internal error"), http.StatusInternalServerError)
}

span.SetAttributes(attribute.String("reset.outcome", "success"))
return server.JSON(w, http.StatusNoContent, nil)
}
}

// HandleAuthLogout godoc
//
// @Summary User Logout
Expand Down
Loading
Loading