Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
140 changes: 140 additions & 0 deletions backend/app/api/cli_reset_password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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
}
if strings.TrimSpace(*email) == "" {
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, *email)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if err != nil {
if ent.IsNotFound(err) {
fmt.Fprintf(os.Stderr, "no account found for %s\n", *email)
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
}
118 changes: 118 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,124 @@ 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 to avoid leaking whether the email is registered.
// @Tags Authentication
// @Accept application/json
// @Param payload body ForgotPasswordRequest true "Email"
// @Success 204
// @Failure 400 {string} string "local login is disabled or SMTP is not configured"
// @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.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 400.
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,
)
}

baseURL := GetHBURL(r, &ctrl.config.Options, ctrl.url)

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
// @Param payload body ResetPasswordRequest true "Token + new password"
// @Success 204
// @Failure 400 {string} string "invalid token, expired token, or missing field"
// @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.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
8 changes: 8 additions & 0 deletions backend/app/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"runtime"
"strings"
"time"
Expand Down Expand Up @@ -95,6 +96,12 @@ func validatePostgresSSLMode(sslMode string) bool {
func main() {
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack

// Subcommand dispatch happens before config.New so the conf package never
// sees positional args (which it would treat as an error).
if handled, code := runResetPasswordCLI(os.Args); handled {
os.Exit(code)
}

cfg, err := config.New(build(), "Homebox inventory management system")
if err != nil {
panic(err)
Expand Down Expand Up @@ -240,6 +247,7 @@ func run(cfg *config.Config) error {
services.WithAutoIncrementAssetID(cfg.Options.AutoIncrementAssetID),
services.WithCurrencies(currencyData),
services.WithNotifierConfig(&cfg.Notifier),
services.WithMailer(&app.mailer),
)

ensureAssetIDs(app)
Expand Down
7 changes: 7 additions & 0 deletions backend/app/api/recurring.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ func registerRecurringTasks(app *app, cfg *config.Config, runner *graceful.Runne
}
}))

runner.AddPlugin(NewTask("purge-password-reset-tokens", 24*time.Hour, func(ctx context.Context) {
_, err := app.repos.PasswordResetTokens.PurgeExpired(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to purge expired password reset tokens")
}
}))

runner.AddPlugin(NewTask("purge-invitations", 24*time.Hour, func(ctx context.Context) {
_, err := app.repos.Groups.InvitationPurge(ctx)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions backend/app/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR

r.Post("/users/register", chain.ToHandlerFunc(v1Ctrl.HandleUserRegistration()))
r.Post("/users/login", chain.ToHandlerFunc(v1Ctrl.HandleAuthLogin(providers...), a.mwAuthRateLimit))
r.Post("/users/forgot-password", chain.ToHandlerFunc(v1Ctrl.HandleForgotPassword(), a.mwAuthRateLimit))
r.Post("/users/reset-password", chain.ToHandlerFunc(v1Ctrl.HandleResetPassword(), a.mwAuthRateLimit))

if a.conf.OIDC.Enabled {
r.Get("/users/login/oidc", chain.ToHandlerFunc(v1Ctrl.HandleOIDCLogin(), a.mwAuthRateLimit))
Expand Down
Loading
Loading