diff --git a/AGENTS.md b/AGENTS.md index f3695d0..b6a20bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,8 @@ internal/web/dist → Embedded Flutter web build (copied by `make web`) | `cmd/grom/` | Main binary; example configs in `config-examples/` | | `api/v1/` | Gin handlers, route registration, DTO/response types | | `api/docs/` | `swag`-generated OpenAPI (`make doc`) | -| `internal/auth/` | JWT + password hashing + `AuthRequired` middleware | +| `internal/auth/` | JWT + password hashing + `AuthRequired` middleware; password reset under `internal/auth/reset/` | +| `internal/mailer/` | Outbound email (`off` / `log` / `smtp` via go-mail) | | `internal/config/` | Viper YAML config; global `config.Cfg` | | `internal/logging/` | `slog` setup from `logging.level` / `logging.format` | | `internal/users/` | User repository + models | @@ -156,6 +157,7 @@ TLS / federation / storage are documented in `docs/admin/configuration.md` (inst 8. **Avatars:** local users + federated author avatar cache; public federation avatar routes differ from authenticated API avatar routes. 9. **Speed chart:** pre-downsampled series (≤500 pts) written at track attach; `GET /workouts/{id}/speed` reads chart only. File driver: `speed-chart.json` blob (JSON for debuggability); bbolt driver: packed binary values in `speed_charts` / `fed_speed_charts` buckets (tracks/media stay on FS). 10. **Heart rate chart:** same pattern as speed (`heartrate-chart.json` on file; packed binary in bbolt `heart_rate_charts` / `fed_heart_rate_charts`); `GET /workouts/{id}/heartrate`; `distance_m` omitted without GPS; X axis is distance km or elapsed minutes from first HR sample. +11. **Password reset:** optional; enabled when `mailer.driver` is `log`/`smtp` and `auth.reset.public_base_url` is set. API `POST /auth/password/forgot` and `/auth/password/reset`; tokens in `reset_tokens.yaml` / bbolt `reset_tokens` (not migrated). UI: Forgot password on login + web `/reset-password` (mobile opens email link in browser). `password_reset_enabled` on `/server-info`. ## Agent do / don't @@ -189,6 +191,7 @@ TLS / federation / storage are documented in `docs/admin/configuration.md` (inst | Track parsing/stats | `internal/tracks/` | | Flutter screen/API | `ui/grom/lib/pages/`, `api_request.dart` | | Config / TLS listen | `internal/config/`, `internal/server/`; human docs in `docs/admin/configuration.md` | +| Password reset / mailer | `internal/auth/reset/`, `internal/mailer/`, `api/v1/auth_password.go`; docs in `docs/admin/configuration.md` | | Logging | `internal/logging/`, `logging:` in `cmd/grom/config-examples/` | | Human docs | `docs/README.md` (index), `docs/user/`, `docs/admin/`; keep `README.md` short | | Version bump / release | edit `VERSION`; move `CHANGELOG.md` `[Unreleased]` → `## [X.Y.Z] - YYYY-MM-DD`; update compare links; tag `X.Y.Z` on master (CI fills release body from changelog) | diff --git a/CHANGELOG.md b/CHANGELOG.md index a250e07..7e6d149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Password reset via email: `POST /api/v1/auth/password/forgot` and `/reset`, opaque tokens (`reset_tokens.yaml` / bbolt `reset_tokens`), `mailer` config (log/smtp via go-mail, no local MTA), `auth.reset.public_base_url`, in-memory rate limits, and `password_reset_enabled` on `/server-info` +- Web UI reset page (`/reset-password`) and Forgot password flow on the login screen (mobile opens the email link in a browser) - Mobile login/register: when the server field has no scheme or port, probe `GET /api/v1/status` over HTTPS then HTTP, update the field with the resolved URL (TLS/certificate errors still select HTTPS; if both fail, default to HTTPS as before) - Android release allows cleartext HTTP so the client can reach local/LAN instances without TLS; iOS `Info.plist` sets `NSAllowsLocalNetworking` for the same local-HTTP case diff --git a/README.md b/README.md index 3faf0bd..a5badee 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ Then open `http://localhost:8080/` for the web UI, or `http://localhost:8080/api ## Documentation - **[Docs index](docs/README.md)** — user and admin guides -- [User overview](docs/user/overview.md) — client screens (workouts, likes, comments, recording, equipment) +- [User overview](docs/user/overview.md) — client screens (workouts, likes, comments, password reset, recording, equipment) - [Install and run](docs/admin/install.md) — build and start the server -- [Configuration](docs/admin/configuration.md) — TLS, storage, federation, logging +- [Configuration](docs/admin/configuration.md) — TLS, storage, federation, logging, mailer / password reset - API docs — `/api/docs/` on a running server (OpenAPI sources in [`api/docs/`](api/docs/)) ## License diff --git a/api/docs/docs.go b/api/docs/docs.go index 5da7c6a..5e98f5e 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -232,6 +232,104 @@ const docTemplate = `{ } } }, + "/auth/password/forgot": { + "post": { + "description": "Sends a password reset email if the account exists. Always returns 204 when reset is enabled (except rate limits).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Request password reset", + "parameters": [ + { + "description": "Account email", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.forgotPasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + } + } + } + }, + "/auth/password/reset": { + "post": { + "description": "Sets a new password using a one-time token from the reset email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Reset password with token", + "parameters": [ + { + "description": "Reset token and new password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.resetPasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + } + } + } + }, "/auth/register": { "post": { "description": "Create a new user account", @@ -3199,6 +3297,36 @@ const docTemplate = `{ "example": "2026-07-05T14:30:01Z" } } + }, + "v1.forgotPasswordRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "example": "solarwind.palm@gmail.com" + } + } + }, + "v1.resetPasswordRequest": { + "type": "object", + "required": [ + "password", + "token" + ], + "properties": { + "password": { + "type": "string", + "minLength": 8, + "example": "secret123" + }, + "token": { + "type": "string", + "example": "abc123" + } + } } }, "securityDefinitions": { diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 87385e4..6e5bce1 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -226,6 +226,104 @@ } } }, + "/auth/password/forgot": { + "post": { + "description": "Sends a password reset email if the account exists. Always returns 204 when reset is enabled (except rate limits).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Request password reset", + "parameters": [ + { + "description": "Account email", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.forgotPasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + } + } + } + }, + "/auth/password/reset": { + "post": { + "description": "Sets a new password using a one-time token from the reset email", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Reset password with token", + "parameters": [ + { + "description": "Reset token and new password", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1.resetPasswordRequest" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "$ref": "#/definitions/v1.ErrorResponse" + } + } + } + } + }, "/auth/register": { "post": { "description": "Create a new user account", @@ -3193,6 +3291,36 @@ "example": "2026-07-05T14:30:01Z" } } + }, + "v1.forgotPasswordRequest": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "example": "solarwind.palm@gmail.com" + } + } + }, + "v1.resetPasswordRequest": { + "type": "object", + "required": [ + "password", + "token" + ], + "properties": { + "password": { + "type": "string", + "minLength": 8, + "example": "secret123" + }, + "token": { + "type": "string", + "example": "abc123" + } + } } }, "securityDefinitions": { diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index d7836d3..1f6c7cc 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -731,6 +731,27 @@ definitions: example: "2026-07-05T14:30:01Z" type: string type: object + v1.forgotPasswordRequest: + properties: + email: + example: solarwind.palm@gmail.com + type: string + required: + - email + type: object + v1.resetPasswordRequest: + properties: + password: + example: secret123 + minLength: 8 + type: string + token: + example: abc123 + type: string + required: + - password + - token + type: object host: localhost:8080 info: contact: @@ -876,6 +897,71 @@ paths: summary: Upload current user avatar tags: - auth + /auth/password/forgot: + post: + consumes: + - application/json + description: Sends a password reset email if the account exists. Always returns + 204 when reset is enabled (except rate limits). + parameters: + - description: Account email + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.forgotPasswordRequest' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Request password reset + tags: + - auth + /auth/password/reset: + post: + consumes: + - application/json + description: Sets a new password using a one-time token from the reset email + parameters: + - description: Reset token and new password + in: body + name: body + required: true + schema: + $ref: '#/definitions/v1.resetPasswordRequest' + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + summary: Reset password with token + tags: + - auth /auth/register: post: consumes: diff --git a/api/v1/api_handlers_test.go b/api/v1/api_handlers_test.go index 083212c..3ca7775 100644 --- a/api/v1/api_handlers_test.go +++ b/api/v1/api_handlers_test.go @@ -32,6 +32,9 @@ func TestServerInfoAndStatus(t *testing.T) { if info["federation_enabled"] != false { t.Fatalf("expected federation_enabled=false, got %#v", info) } + if info["password_reset_enabled"] != false { + t.Fatalf("expected password_reset_enabled=false, got %#v", info) + } } func TestUpdateMeAndDeleteAvatar(t *testing.T) { diff --git a/api/v1/app.go b/api/v1/app.go index b6f8b7b..f678b1d 100644 --- a/api/v1/app.go +++ b/api/v1/app.go @@ -4,14 +4,17 @@ import ( "log/slog" "net/http" "sync" + "time" "github.com/gin-gonic/gin" "github.com/solargate/grom/internal/auth" + "github.com/solargate/grom/internal/auth/reset" "github.com/solargate/grom/internal/config" "github.com/solargate/grom/internal/equipment" "github.com/solargate/grom/internal/equipment/distance" "github.com/solargate/grom/internal/federation" "github.com/solargate/grom/internal/integrations/strava" + "github.com/solargate/grom/internal/mailer" "github.com/solargate/grom/internal/social" "github.com/solargate/grom/internal/storage" "github.com/solargate/grom/internal/storage/blob" @@ -30,6 +33,8 @@ type App struct { Social *social.Service Federation federation.Storage Blobs blob.Store + Mailer mailer.Mailer + PasswordReset *reset.Service Location string TempDir string @@ -51,6 +56,28 @@ func NewApp() (*App, error) { socialSvc := social.NewService(backend.Users(), backend.Social(), backend.Blobs()) workoutSvc := backend.Workouts() + + mail, err := mailer.New(config.Cfg.Mailer) + if err != nil { + _ = backend.Close() + return nil, err + } + + var passwordReset *reset.Service + if config.Cfg.PasswordResetEnabled() { + passwordReset = reset.NewService( + backend.Users(), + backend.ResetTokens(), + mail, + reset.Config{ + PublicBaseURL: config.Cfg.Auth.Reset.PublicBaseURL, + TokenTTL: time.Duration(config.Cfg.Auth.Reset.TokenTTLMinutes) * time.Minute, + ServerName: config.Cfg.Server.Name, + Enabled: true, + }, + ) + } + app := &App{ Backend: backend, Users: backend.Users(), @@ -62,6 +89,8 @@ func NewApp() (*App, error) { Social: socialSvc, Federation: backend.Federation(), Blobs: backend.Blobs(), + Mailer: mail, + PasswordReset: passwordReset, Location: config.Cfg.Storage.ResolvedLocation, TempDir: config.Cfg.Storage.ResolvedTempDir, } @@ -158,6 +187,8 @@ func (a *App) RegisterRoutes(router *gin.Engine) { authGroup := apiV1.Group("/auth") authGroup.POST("/register", a.register) authGroup.POST("/login", a.login) + authGroup.POST("/password/forgot", a.forgotPassword) + authGroup.POST("/password/reset", a.resetPassword) authGroup.GET("/me", auth.AuthRequired(), a.getMe) authGroup.PATCH("/me", auth.AuthRequired(), a.updateMe) authGroup.PUT("/me/avatar", auth.AuthRequired(), a.uploadMyAvatar) diff --git a/api/v1/auth_password.go b/api/v1/auth_password.go new file mode 100644 index 0000000..bb098f0 --- /dev/null +++ b/api/v1/auth_password.go @@ -0,0 +1,120 @@ +package v1 + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/solargate/grom/internal/auth/reset" + "github.com/solargate/grom/internal/users" +) + +type forgotPasswordRequest struct { + Email string `json:"email" binding:"required,email" example:"solarwind.palm@gmail.com"` +} + +type resetPasswordRequest struct { + Token string `json:"token" binding:"required" example:"abc123"` + Password string `json:"password" binding:"required,min=8" example:"secret123"` +} + +// forgotPassword godoc +// @Summary Request password reset +// @Description Sends a password reset email if the account exists. Always returns 204 when reset is enabled (except rate limits). +// @Tags auth +// @Accept json +// @Produce json +// @Param body body forgotPasswordRequest true "Account email" +// @Success 204 +// @Failure 400 {object} ErrorResponse +// @Failure 429 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Router /auth/password/forgot [post] +func (a *App) forgotPassword(ctx *gin.Context) { + if a.PasswordReset == nil || !a.PasswordReset.Enabled() { + ctx.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "password reset is not configured"}) + return + } + + var req forgotPasswordRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + email := strings.ToLower(strings.TrimSpace(req.Email)) + if ok, retry := a.PasswordReset.Limiter().AllowForgot(ctx.ClientIP(), email); !ok { + writeRateLimited(ctx, retry) + return + } + + if err := a.PasswordReset.RequestReset(ctx.Request.Context(), email); err != nil { + if errors.Is(err, reset.ErrNotConfigured) { + ctx.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "password reset is not configured"}) + return + } + respondInternal(ctx, "failed to request password reset", err) + return + } + ctx.Status(http.StatusNoContent) +} + +// resetPassword godoc +// @Summary Reset password with token +// @Description Sets a new password using a one-time token from the reset email +// @Tags auth +// @Accept json +// @Produce json +// @Param body body resetPasswordRequest true "Reset token and new password" +// @Success 204 +// @Failure 400 {object} ErrorResponse +// @Failure 429 {object} ErrorResponse +// @Failure 503 {object} ErrorResponse +// @Router /auth/password/reset [post] +func (a *App) resetPassword(ctx *gin.Context) { + if a.PasswordReset == nil || !a.PasswordReset.Enabled() { + ctx.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "password reset is not configured"}) + return + } + + var req resetPasswordRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + + if ok, retry := a.PasswordReset.Limiter().AllowReset(ctx.ClientIP()); !ok { + writeRateLimited(ctx, retry) + return + } + + err := a.PasswordReset.ConfirmReset(ctx.Request.Context(), req.Token, req.Password) + if err != nil { + switch { + case errors.Is(err, reset.ErrNotConfigured): + ctx.JSON(http.StatusServiceUnavailable, ErrorResponse{Error: "password reset is not configured"}) + case errors.Is(err, reset.ErrInvalidToken): + ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid or expired reset token"}) + case errors.Is(err, reset.ErrWeakPassword): + ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + case errors.Is(err, users.ErrUserNotFound): + ctx.JSON(http.StatusBadRequest, ErrorResponse{Error: "invalid or expired reset token"}) + default: + respondInternal(ctx, "failed to reset password", err) + } + return + } + ctx.Status(http.StatusNoContent) +} + +func writeRateLimited(ctx *gin.Context, retryAfter time.Duration) { + secs := int(retryAfter.Seconds()) + if secs < 1 { + secs = 1 + } + ctx.Header("Retry-After", strconv.Itoa(secs)) + ctx.JSON(http.StatusTooManyRequests, ErrorResponse{Error: "too many requests, try again later"}) +} diff --git a/api/v1/auth_password_test.go b/api/v1/auth_password_test.go new file mode 100644 index 0000000..6998507 --- /dev/null +++ b/api/v1/auth_password_test.go @@ -0,0 +1,163 @@ +package v1_test + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "testing" + "time" + + "github.com/solargate/grom/internal/auth/reset" + "github.com/solargate/grom/internal/config" +) + +func TestPasswordResetFlow(t *testing.T) { + ta := setupTestAppWithConfig(t, func(cfg *config.Config) { + cfg.Server.TLS.Mode = "off" + cfg.Server.Name = "Grom Test" + cfg.Federation.Enabled = false + cfg.Auth.Reset.PublicBaseURL = "https://grom.example.com" + cfg.Mailer.Driver = "log" + cfg.Mailer.From = "Grom " + }) + + w := ta.doJSON(t, http.MethodGet, "/api/v1/server-info", nil, "") + expectStatus(t, w, http.StatusOK) + info := decodeObject(t, w) + if info["password_reset_enabled"] != true { + t.Fatalf("expected password_reset_enabled=true: %#v", info) + } + + ta.register(t, "alice", "alice@example.com", "password12") + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/forgot", map[string]string{ + "email": "missing@example.com", + }, "") + expectStatus(t, w, http.StatusNoContent) + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/forgot", map[string]string{ + "email": "alice@example.com", + }, "") + expectStatus(t, w, http.StatusNoContent) + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/reset", map[string]string{ + "token": "not-a-real-token", + "password": "newpassword1", + }, "") + expectStatus(t, w, http.StatusBadRequest) + + raw := "test-reset-token-raw-value-AAAAAAAA" + sum := sha256.Sum256([]byte(raw)) + hash := hex.EncodeToString(sum[:]) + user, err := ta.app.Users.FindByEmail("alice@example.com") + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := ta.app.Backend.ResetTokens().ReplaceForUser(user.ID, reset.TokenRecord{ + TokenHash: hash, + UserID: user.ID, + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, + }); err != nil { + t.Fatal(err) + } + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/reset", map[string]string{ + "token": raw, + "password": "newpassword1", + }, "") + expectStatus(t, w, http.StatusNoContent) + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/login", map[string]string{ + "email": "alice@example.com", + "password": "password12", + }, "") + expectStatus(t, w, http.StatusUnauthorized) + + _, _ = ta.login(t, "alice@example.com", "newpassword1") + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/reset", map[string]string{ + "token": raw, + "password": "anotherpass", + }, "") + expectStatus(t, w, http.StatusBadRequest) +} + +func TestPasswordResetDisabled(t *testing.T) { + ta := setupTestApp(t) + w := ta.doJSON(t, http.MethodGet, "/api/v1/server-info", nil, "") + expectStatus(t, w, http.StatusOK) + info := decodeObject(t, w) + if info["password_reset_enabled"] != false { + t.Fatalf("expected disabled: %#v", info) + } + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/forgot", map[string]string{ + "email": "a@b.c", + }, "") + expectStatus(t, w, http.StatusServiceUnavailable) + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/reset", map[string]string{ + "token": "any-token-value", + "password": "password12", + }, "") + expectStatus(t, w, http.StatusServiceUnavailable) +} + +func TestPasswordReset_RateLimited(t *testing.T) { + ta := setupTestAppWithConfig(t, func(cfg *config.Config) { + cfg.Server.TLS.Mode = "off" + cfg.Federation.Enabled = false + cfg.Auth.Reset.PublicBaseURL = "https://grom.example.com" + cfg.Mailer.Driver = "log" + cfg.Mailer.From = "Grom " + }) + + for i := 0; i < 3; i++ { + w := ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/forgot", map[string]string{ + "email": "rate@example.com", + }, "") + expectStatus(t, w, http.StatusNoContent) + } + w := ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/forgot", map[string]string{ + "email": "rate@example.com", + }, "") + expectStatus(t, w, http.StatusTooManyRequests) + if w.Header().Get("Retry-After") == "" { + t.Fatal("expected Retry-After header") + } +} + +func TestForgotPassword_BadRequest(t *testing.T) { + ta := setupTestAppWithConfig(t, func(cfg *config.Config) { + cfg.Server.TLS.Mode = "off" + cfg.Federation.Enabled = false + cfg.Auth.Reset.PublicBaseURL = "https://grom.example.com" + cfg.Mailer.Driver = "log" + cfg.Mailer.From = "Grom " + }) + + w := ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/forgot", map[string]string{ + "email": "not-an-email", + }, "") + expectStatus(t, w, http.StatusBadRequest) + + w = ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/forgot", map[string]string{}, "") + expectStatus(t, w, http.StatusBadRequest) +} + +func TestResetPassword_WeakPassword(t *testing.T) { + ta := setupTestAppWithConfig(t, func(cfg *config.Config) { + cfg.Server.TLS.Mode = "off" + cfg.Federation.Enabled = false + cfg.Auth.Reset.PublicBaseURL = "https://grom.example.com" + cfg.Mailer.Driver = "log" + cfg.Mailer.From = "Grom " + }) + + w := ta.doJSON(t, http.MethodPost, "/api/v1/auth/password/reset", map[string]string{ + "token": "some-token", + "password": "short", + }, "") + expectStatus(t, w, http.StatusBadRequest) +} diff --git a/api/v1/server_info.go b/api/v1/server_info.go index 699b2cc..5779b78 100644 --- a/api/v1/server_info.go +++ b/api/v1/server_info.go @@ -29,7 +29,8 @@ func (a *App) checkStatus(ctx *gin.Context) { // @Router /server-info [get] func (a *App) getServerInfo(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{ - "name": config.Cfg.Server.Name, - "federation_enabled": config.Cfg.Federation.Enabled, + "name": config.Cfg.Server.Name, + "federation_enabled": config.Cfg.Federation.Enabled, + "password_reset_enabled": config.Cfg.PasswordResetEnabled(), }) } diff --git a/cmd/grom/config-examples/config.dev.notls.yaml b/cmd/grom/config-examples/config.dev.notls.yaml index 8d66470..43713b3 100644 --- a/cmd/grom/config-examples/config.dev.notls.yaml +++ b/cmd/grom/config-examples/config.dev.notls.yaml @@ -10,6 +10,13 @@ federation: auth: jwt_secret: "change-me-in-production-min-32-chars!!" jwt_ttl_hours: 24 + reset: + public_base_url: "http://localhost:8080" + +mailer: + # log: reset emails appear in server logs (copy the link for local testing). + driver: log + from: "Grom " storage: location: data diff --git a/cmd/grom/config-examples/config.dev.tls.yaml b/cmd/grom/config-examples/config.dev.tls.yaml index a5bd70f..4075ba7 100644 --- a/cmd/grom/config-examples/config.dev.tls.yaml +++ b/cmd/grom/config-examples/config.dev.tls.yaml @@ -17,6 +17,12 @@ federation: auth: jwt_secret: "change-me-in-production-min-32-chars!!" jwt_ttl_hours: 24 + reset: + public_base_url: "https://192.168.1.251:8443" + +mailer: + driver: log + from: "Grom " storage: location: data diff --git a/cmd/grom/config-examples/config.full.yaml b/cmd/grom/config-examples/config.full.yaml index ca54e59..3670286 100644 --- a/cmd/grom/config-examples/config.full.yaml +++ b/cmd/grom/config-examples/config.full.yaml @@ -54,6 +54,29 @@ auth: jwt_secret: "REPLACE_WITH_STRONG_SECRET" # Optional. JWT lifetime in hours. Default: 24. jwt_ttl_hours: 24 + reset: + # Required when mailer.driver is log or smtp. Public base URL for reset links (no trailing slash). + public_base_url: "https://grom.example.com" + # Optional. Reset token lifetime in minutes. Default: 60. + token_ttl_minutes: 60 + +mailer: + # Optional. Outbound email driver: off | log | smtp. Default: off. + # log writes messages to the server log (dev). smtp uses an external SMTP relay (no local MTA). + driver: off + # Required when driver is log or smtp. Sender address, e.g. "Grom ". + from: "" + smtp: + # Required when driver is smtp. + host: "" + # Required when driver is smtp. Common: 587 (STARTTLS) or 465 (implicit TLS). + port: 587 + # Optional. SMTP username (leave empty for open local catchers). + username: "" + # Optional. SMTP password. + password: "" + # Optional. starttls | tls | none. Default: starttls (or tls when port is 465). + encryption: starttls storage: # Optional. Storage backend driver: file | bbolt | postgres. Default: file. diff --git a/cmd/grom/config-examples/config.prod.notls.yaml b/cmd/grom/config-examples/config.prod.notls.yaml index d49eb68..409e021 100644 --- a/cmd/grom/config-examples/config.prod.notls.yaml +++ b/cmd/grom/config-examples/config.prod.notls.yaml @@ -10,6 +10,18 @@ federation: auth: jwt_secret: "REPLACE_WITH_STRONG_SECRET" jwt_ttl_hours: 24 + # reset: + # public_base_url: "https://grom.example.com" + +mailer: + driver: off + # from: "Grom " + # smtp: + # host: smtp.example.com + # port: 587 + # username: "" + # password: "" + # encryption: starttls storage: location: /var/lib/grom/data diff --git a/cmd/grom/config-examples/config.prod.tls.yaml b/cmd/grom/config-examples/config.prod.tls.yaml index b2ace00..970a7a9 100644 --- a/cmd/grom/config-examples/config.prod.tls.yaml +++ b/cmd/grom/config-examples/config.prod.tls.yaml @@ -16,6 +16,18 @@ federation: auth: jwt_secret: "REPLACE_WITH_STRONG_SECRET" jwt_ttl_hours: 24 + reset: + public_base_url: "https://grom.example.com" + +mailer: + driver: smtp + from: "Grom " + smtp: + host: smtp.example.com + port: 587 + username: "" + password: "" + encryption: starttls storage: location: /var/lib/grom/data diff --git a/docs/README.md b/docs/README.md index 8001e1a..a8927f5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ English documentation for Grom. The [root README](../README.md) is a short entry | Goal | Page | |------|------| | See what the client can do (workouts, likes, comments, recording, equipment) | [User overview](user/overview.md) | +| Reset a forgotten password (when the operator enables email) | [User overview — Sign-in and password reset](user/overview.md#sign-in-and-password-reset) | | Use Grom in a browser (same UI as Android) | Open the server base URL after [install](admin/install.md); see [User overview](user/overview.md) | | Import a Strava export (UI + how import works) | [Strava bulk import](strava-bulk-import.md) | | Import Health Sync activities from Google Drive (Android) | [Health Sync + Google Drive](health-sync-google-drive.md) | @@ -18,7 +19,7 @@ English documentation for Grom. The [root README](../README.md) is a short entry | Goal | Page | |------|------| | Build and run the server | [Install and run](admin/install.md) | -| Configure TLS, storage, federation, logging | [Configuration](admin/configuration.md) | +| Configure TLS, storage, federation, logging, mailer / password reset | [Configuration](admin/configuration.md) | ### Reference diff --git a/docs/admin/configuration.md b/docs/admin/configuration.md index f75542a..8002c95 100644 --- a/docs/admin/configuration.md +++ b/docs/admin/configuration.md @@ -15,6 +15,7 @@ Grom is configured with a YAML file. By default it looks for `config.yaml` in th | `storage.driver` / `location` / `temp_dir` | `file` (default; tests / tiny instances) or `bbolt` (recommended for normal installs); data root and temp dirs | | `storage.bbolt.path` | Optional path to `grom.db` when using bbolt (default: `{location}/grom.db`) | | `federation.enabled` / `federation.domain` | ActivityPub; requires HTTPS | +| `auth.reset` / `mailer` | Password reset email (`public_base_url`, SMTP or log driver) | | `logging.level` / `logging.format` | `debug`/`info`/`warn`/`error`; `text` (dev) or `json` (prod). Defaults: `info` + `json` | Relative paths in `storage.*`, `server.tls.cert_file` / `key_file`, `server.tls.autocert.cache_dir`, and `federation.ca_cert_file` are resolved against the directory of the `grom` binary (absolute paths are used as-is). @@ -71,6 +72,44 @@ grom migrate-storage --config config.yaml --from bbolt --to file --verify Use `--dry-run` to count records without writing, and `--force` to overwrite an existing bbolt database. +Password-reset tokens (`reset_tokens.yaml` / bbolt `reset_tokens`) are short-lived and are **not** copied by migrate-storage; in-flight reset links become invalid after a migrate. + +## Mailer and password reset + +Outbound email is optional. When `mailer.driver` is `off` (default), password reset is disabled and `GET /api/v1/server-info` reports `password_reset_enabled: false`. + +| Setting | Purpose | +|---------|---------| +| `auth.reset.public_base_url` | Base URL embedded in reset links (no trailing slash). Required when mailer is on. | +| `auth.reset.token_ttl_minutes` | Token lifetime (default `60`) | +| `mailer.driver` | `off`, `log` (write to server log — useful in dev), or `smtp` | +| `mailer.from` | Sender address | +| `mailer.smtp.host` / `port` | SMTP relay (required when `driver` is `smtp`). Common ports: `587` (STARTTLS), `465` (implicit TLS) | +| `mailer.smtp.username` / `password` | Optional SMTP credentials | +| `mailer.smtp.encryption` | `starttls` (default; also default for port 587), `tls` (default when port is `465`), or `none` | + +There is **no** local MTA / `sendmail` dependency: the process speaks SMTP (via [go-mail](https://github.com/wneessen/go-mail)) to an external provider (Gmail app password, SES, Mailgun, etc.) or logs the message when `driver: log`. + +Password-reset endpoints use an in-memory fixed-window rate limiter (15-minute window): forgot — 10 requests per client IP and 3 per email; confirm reset — 20 per client IP. Limits use Gin’s `ClientIP()` (honors `X-Forwarded-For` / `X-Real-IP` when present). Grom does not yet expose a trusted-proxies setting, so treat forwarded headers as untrusted unless your reverse proxy strips or overwrites them. + +Example (production SMTP on port 587): + +```yaml +auth: + jwt_secret: "..." + reset: + public_base_url: "https://grom.example.com" +mailer: + driver: smtp + from: "Grom " + smtp: + host: smtp.example.com + port: 587 + username: "apikey" + password: "secret" + encryption: starttls +``` + ## See also - [Install and run](install.md) diff --git a/docs/admin/install.md b/docs/admin/install.md index 1ac23b1..51a5e8e 100644 --- a/docs/admin/install.md +++ b/docs/admin/install.md @@ -1,6 +1,6 @@ # Install and run -Install Grom from a GitHub release or build from source. For configuration details (TLS, storage, federation), see [Configuration](configuration.md). +Install Grom from a GitHub release or build from source. For configuration details (TLS, storage, federation, mailer / password reset), see [Configuration](configuration.md). ## Download from GitHub Releases @@ -80,6 +80,7 @@ grom --version ## Next steps - Choose a TLS profile and storage driver (`bbolt` for normal installs; `file` is mainly for tests / tiny instances) — [Configuration](configuration.md) +- Optionally enable password reset email (`mailer` + `auth.reset.public_base_url`) — [Configuration — Mailer and password reset](configuration.md#mailer-and-password-reset) - Generate self-signed certs for local HTTPS — `grom gencerts` (see TLS section in configuration) - Product tour for the client — [User overview](../user/overview.md) - API reference in the browser — `/api/docs/` on the running server diff --git a/docs/user/overview.md b/docs/user/overview.md index 9738ed7..5db0cc0 100644 --- a/docs/user/overview.md +++ b/docs/user/overview.md @@ -4,6 +4,10 @@ Grom’s Flutter client runs as a **web UI** and as an **Android** app. The web On **Android** (and later iOS), sign-in and registration ask for a **server URL**. You can enter a bare host such as `grom.example.com` (no `https://` required). On submit the app probes `GET /api/v1/status` over HTTPS, then HTTP, writes the resolved URL into the field, and continues. If you already type `http://` / `https://` or an explicit port, that value is used as-is. **HTTP is supported for local / LAN instances** without TLS; prefer HTTPS for anything reachable on the public internet. +## Sign-in and password reset + +If the operator enables outbound email (`mailer` in server config), the sign-in screen shows **Forgot password?**. Enter your account email; the server always responds the same way whether or not the address is registered. Check your inbox for a reset link and open it in a **browser** (the web UI at `/reset-password`). After you set a new password, sign in again in the app or on the web. Password reset is unavailable when the server reports `password_reset_enabled: false`. + This page is a short tour of the main screens (screenshots below are from Android). Admin setup (install, config, TLS, federation) lives under [Admin docs](../README.md#admin). For the HTTP API, see Swagger at `/api/docs/` on a running server. ## Workouts diff --git a/go.mod b/go.mod index c8272a9..99fd1ca 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,9 @@ require ( github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 github.com/tkrajina/gpxgo v1.4.0 + github.com/wneessen/go-mail v0.8.1 go.etcd.io/bbolt v1.5.0 - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.54.0 golang.org/x/image v0.28.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -77,9 +78,9 @@ require ( golang.org/x/arch v0.28.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.56.0 // indirect - golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index b712f8f..ede2fb9 100644 --- a/go.sum +++ b/go.sum @@ -161,6 +161,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM= +github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= @@ -178,8 +180,8 @@ golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -194,8 +196,8 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -203,8 +205,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -213,8 +215,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/internal/auth/reset/errors.go b/internal/auth/reset/errors.go new file mode 100644 index 0000000..b413dfc --- /dev/null +++ b/internal/auth/reset/errors.go @@ -0,0 +1,14 @@ +package reset + +import "errors" + +var ( + // ErrNotConfigured means password reset / mailer is not enabled. + ErrNotConfigured = errors.New("password reset is not configured") + // ErrInvalidToken means the token is missing, unknown, or expired. + ErrInvalidToken = errors.New("invalid or expired reset token") + // ErrRateLimited means too many requests. + ErrRateLimited = errors.New("too many requests") + // ErrWeakPassword means the new password does not meet policy. + ErrWeakPassword = errors.New("password must be at least 8 characters") +) diff --git a/internal/auth/reset/ratelimit.go b/internal/auth/reset/ratelimit.go new file mode 100644 index 0000000..921a02f --- /dev/null +++ b/internal/auth/reset/ratelimit.go @@ -0,0 +1,94 @@ +package reset + +import ( + "sync" + "time" +) + +const ( + forgotPerIPLimit = 10 + forgotPerEmailLimit = 3 + resetPerIPLimit = 20 + rateLimitWindow = 15 * time.Minute +) + +// Limiter is an in-memory fixed-window rate limiter for password reset endpoints. +type Limiter struct { + mu sync.Mutex + windows map[string]*limitWindow +} + +type limitWindow struct { + count int + resetAt time.Time +} + +// NewLimiter creates an empty rate limiter. +func NewLimiter() *Limiter { + return &Limiter{windows: make(map[string]*limitWindow)} +} + +// AllowForgot checks IP and email limits for forgot-password requests. +// On success both counters are incremented. retryAfter is set when limited. +func (l *Limiter) AllowForgot(ip, email string) (ok bool, retryAfter time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + l.pruneLocked(now) + + ipKey := "forgot:ip:" + ip + emailKey := "forgot:email:" + email + + if retry, limited := l.checkLocked(ipKey, forgotPerIPLimit, now); limited { + return false, retry + } + if retry, limited := l.checkLocked(emailKey, forgotPerEmailLimit, now); limited { + return false, retry + } + l.hitLocked(ipKey, now) + l.hitLocked(emailKey, now) + return true, 0 +} + +// AllowReset checks the IP limit for confirm-reset requests. +func (l *Limiter) AllowReset(ip string) (ok bool, retryAfter time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + l.pruneLocked(now) + + key := "reset:ip:" + ip + if retry, limited := l.checkLocked(key, resetPerIPLimit, now); limited { + return false, retry + } + l.hitLocked(key, now) + return true, 0 +} + +func (l *Limiter) checkLocked(key string, limit int, now time.Time) (retryAfter time.Duration, limited bool) { + w := l.windows[key] + if w == nil || !now.Before(w.resetAt) { + return 0, false + } + if w.count >= limit { + return time.Until(w.resetAt).Truncate(time.Second), true + } + return 0, false +} + +func (l *Limiter) hitLocked(key string, now time.Time) { + w := l.windows[key] + if w == nil || !now.Before(w.resetAt) { + l.windows[key] = &limitWindow{count: 1, resetAt: now.Add(rateLimitWindow)} + return + } + w.count++ +} + +func (l *Limiter) pruneLocked(now time.Time) { + for k, w := range l.windows { + if !now.Before(w.resetAt) { + delete(l.windows, k) + } + } +} diff --git a/internal/auth/reset/service.go b/internal/auth/reset/service.go new file mode 100644 index 0000000..ee7db46 --- /dev/null +++ b/internal/auth/reset/service.go @@ -0,0 +1,193 @@ +package reset + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "html" + "log/slog" + "strings" + "time" + + "github.com/solargate/grom/internal/auth" + "github.com/solargate/grom/internal/mailer" + "github.com/solargate/grom/internal/users" +) + +const minPasswordLen = 8 + +// Config holds reset service settings. +type Config struct { + PublicBaseURL string + TokenTTL time.Duration + ServerName string + Enabled bool +} + +// Service handles password-reset request and confirmation. +type Service struct { + users users.Repository + tokens TokenStore + mailer mailer.Mailer + cfg Config + limit *Limiter +} + +// NewService wires a password-reset service. +func NewService(usersRepo users.Repository, tokens TokenStore, m mailer.Mailer, cfg Config) *Service { + if cfg.TokenTTL <= 0 { + cfg.TokenTTL = time.Hour + } + if strings.TrimSpace(cfg.ServerName) == "" { + cfg.ServerName = "Grom" + } + return &Service{ + users: usersRepo, + tokens: tokens, + mailer: m, + cfg: cfg, + limit: NewLimiter(), + } +} + +// Enabled reports whether reset is configured. +func (s *Service) Enabled() bool { + return s != nil && s.cfg.Enabled +} + +// Limiter exposes the in-memory rate limiter (for handlers). +func (s *Service) Limiter() *Limiter { + if s == nil { + return nil + } + return s.limit +} + +// RequestReset starts a password reset for email. Unknown emails are a no-op. +func (s *Service) RequestReset(ctx context.Context, email string) error { + if !s.Enabled() { + return ErrNotConfigured + } + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return nil + } + + user, err := s.users.FindByEmail(email) + if err != nil { + if errors.Is(err, users.ErrUserNotFound) { + return nil + } + return err + } + + raw, hash, err := newToken() + if err != nil { + return err + } + now := time.Now().UTC() + rec := TokenRecord{ + TokenHash: hash, + UserID: user.ID, + ExpiresAt: now.Add(s.cfg.TokenTTL), + CreatedAt: now, + } + if err := s.tokens.ReplaceForUser(user.ID, rec); err != nil { + return err + } + + link := strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/reset-password?token=" + raw + msg := buildResetMessage(s.cfg.ServerName, user.Email, link, s.cfg.TokenTTL) + if err := s.mailer.Send(ctx, msg); err != nil { + _ = s.tokens.DeleteByHash(hash) + slog.Error("password_reset_email_failed", "user_id", user.ID, "err", err) + return err + } + slog.Info("password_reset_requested", "user_id", user.ID) + return nil +} + +// ConfirmReset sets a new password using a one-time token. +func (s *Service) ConfirmReset(ctx context.Context, rawToken, newPassword string) error { + if !s.Enabled() { + return ErrNotConfigured + } + _ = ctx + rawToken = strings.TrimSpace(rawToken) + if rawToken == "" { + return ErrInvalidToken + } + if len(newPassword) < minPasswordLen { + return ErrWeakPassword + } + + hash := hashToken(rawToken) + rec, err := s.tokens.GetByHash(hash) + if err != nil { + return err + } + + passwordHash, err := auth.HashPassword(newPassword) + if err != nil { + return err + } + if err := s.users.UpdatePassword(rec.UserID, passwordHash); err != nil { + return err + } + if err := s.tokens.DeleteByHash(hash); err != nil { + slog.Warn("password_reset_token_delete_failed", "user_id", rec.UserID, "err", err) + } + slog.Info("password_reset_completed", "user_id", rec.UserID) + return nil +} + +func newToken() (raw, hash string, err error) { + buf := make([]byte, 32) + if _, err = rand.Read(buf); err != nil { + return "", "", fmt.Errorf("generate reset token: %w", err) + } + raw = base64.RawURLEncoding.EncodeToString(buf) + return raw, hashToken(raw), nil +} + +func hashToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +func buildResetMessage(serverName, to, link string, ttl time.Duration) mailer.Message { + minutes := int(ttl.Round(time.Minute) / time.Minute) + if minutes < 1 { + minutes = 1 + } + subject := fmt.Sprintf("Reset your %s password", serverName) + text := fmt.Sprintf( + "Hello,\n\n"+ + "We received a request to reset the password for your %s account (%s).\n\n"+ + "Open this link to choose a new password:\n%s\n\n"+ + "This link expires in %d minutes and can be used only once.\n\n"+ + "If you did not request a password reset, you can ignore this email.\n", + serverName, to, link, minutes, + ) + safeLink := html.EscapeString(link) + safeName := html.EscapeString(serverName) + safeTo := html.EscapeString(to) + htmlBody := fmt.Sprintf( + "

Hello,

"+ + "

We received a request to reset the password for your %s account (%s).

"+ + "

Reset your password

"+ + "

This link expires in %d minutes and can be used only once.

"+ + "

If you did not request a password reset, you can ignore this email.

", + safeName, safeTo, safeLink, minutes, + ) + return mailer.Message{ + To: []string{to}, + Subject: subject, + Text: text, + HTML: htmlBody, + } +} diff --git a/internal/auth/reset/service_test.go b/internal/auth/reset/service_test.go new file mode 100644 index 0000000..1ee9a08 --- /dev/null +++ b/internal/auth/reset/service_test.go @@ -0,0 +1,393 @@ +package reset_test + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/solargate/grom/internal/auth" + "github.com/solargate/grom/internal/auth/reset" + "github.com/solargate/grom/internal/mailer" + "github.com/solargate/grom/internal/users" +) + +type memMailer struct { + mu sync.Mutex + msgs []mailer.Message + err error +} + +func (m *memMailer) Send(_ context.Context, msg mailer.Message) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.err != nil { + return m.err + } + m.msgs = append(m.msgs, msg) + return nil +} + +func (m *memMailer) last() mailer.Message { + m.mu.Lock() + defer m.mu.Unlock() + if len(m.msgs) == 0 { + return mailer.Message{} + } + return m.msgs[len(m.msgs)-1] +} + +type memUsers struct { + mu sync.Mutex + byID map[string]*users.User + email map[string]string +} + +func newMemUsers(u *users.User) *memUsers { + m := &memUsers{ + byID: map[string]*users.User{u.ID: u}, + email: map[string]string{strings.ToLower(u.Email): u.ID}, + } + return m +} + +func (m *memUsers) FindByEmail(email string) (*users.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + id, ok := m.email[strings.ToLower(strings.TrimSpace(email))] + if !ok { + return nil, users.ErrUserNotFound + } + return m.byID[id], nil +} +func (m *memUsers) FindByID(id string) (*users.User, error) { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.byID[id] + if !ok { + return nil, users.ErrUserNotFound + } + return u, nil +} +func (m *memUsers) FindByNickname(string) (*users.User, error) { return nil, users.ErrUserNotFound } +func (m *memUsers) Search(string, string, int) ([]users.User, error) { + return nil, nil +} +func (m *memUsers) ListAll() ([]users.User, error) { return nil, nil } +func (m *memUsers) Create(string, string, string, string) (*users.User, error) { + return nil, users.ErrUserNotFound +} +func (m *memUsers) UpdateProfile(string, string) (*users.User, error) { + return nil, users.ErrUserNotFound +} +func (m *memUsers) UpdatePassword(userID, passwordHash string) error { + m.mu.Lock() + defer m.mu.Unlock() + u, ok := m.byID[userID] + if !ok { + return users.ErrUserNotFound + } + u.PasswordHash = passwordHash + return nil +} +func (m *memUsers) GetProfile(string) (*users.Profile, error) { return &users.Profile{}, nil } +func (m *memUsers) PutProfile(string, users.Profile) error { return nil } +func (m *memUsers) SetLastSportType(string, string) error { return nil } +func (m *memUsers) SetLastEquipmentForSport(string, string, []string) error { + return nil +} +func (m *memUsers) RemoveEquipmentFromLastSets(string, string) error { return nil } + +type memTokens struct { + mu sync.Mutex + byHash map[string]reset.TokenRecord +} + +func (s *memTokens) ReplaceForUser(userID string, record reset.TokenRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.byHash == nil { + s.byHash = map[string]reset.TokenRecord{} + } + for h, r := range s.byHash { + if r.UserID == userID { + delete(s.byHash, h) + } + } + s.byHash[record.TokenHash] = record + return nil +} + +func (s *memTokens) GetByHash(hash string) (*reset.TokenRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + r, ok := s.byHash[hash] + if !ok { + return nil, reset.ErrInvalidToken + } + if !r.ExpiresAt.After(time.Now().UTC()) { + delete(s.byHash, hash) + return nil, reset.ErrInvalidToken + } + cp := r + return &cp, nil +} + +func (s *memTokens) DeleteByHash(hash string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.byHash, hash) + return nil +} + +func TestRequestAndConfirmReset(t *testing.T) { + hash, err := auth.HashPassword("oldpassword") + if err != nil { + t.Fatal(err) + } + u := &users.User{ID: "u1", Email: "alice@example.com", PasswordHash: hash} + usersRepo := newMemUsers(u) + tokens := &memTokens{} + mail := &memMailer{} + svc := reset.NewService(usersRepo, tokens, mail, reset.Config{ + PublicBaseURL: "https://grom.example.com", + TokenTTL: time.Hour, + ServerName: "Grom", + Enabled: true, + }) + + if err := svc.RequestReset(context.Background(), "unknown@example.com"); err != nil { + t.Fatal(err) + } + if mail.last().Subject != "" { + t.Fatal("expected no mail for unknown email") + } + + if err := svc.RequestReset(context.Background(), "Alice@Example.com"); err != nil { + t.Fatal(err) + } + msg := mail.last() + if msg.Subject == "" || msg.Text == "" || msg.HTML == "" { + t.Fatalf("expected email content, got %#v", msg) + } + idx := strings.Index(msg.Text, "token=") + if idx < 0 { + t.Fatalf("token missing in text: %s", msg.Text) + } + raw := strings.TrimSpace(msg.Text[idx+len("token="):]) + if i := strings.IndexAny(raw, "\n\r "); i >= 0 { + raw = raw[:i] + } + + if err := svc.ConfirmReset(context.Background(), raw, "newpassword"); err != nil { + t.Fatal(err) + } + if !auth.CheckPassword(u.PasswordHash, "newpassword") { + t.Fatal("password not updated") + } + if err := svc.ConfirmReset(context.Background(), raw, "anotherpass"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("reuse: %v", err) + } +} + +func TestConfirmResetWeakPassword(t *testing.T) { + svc := reset.NewService(newMemUsers(&users.User{ID: "u1", Email: "a@b.c"}), &memTokens{}, &memMailer{}, reset.Config{ + PublicBaseURL: "http://localhost", + Enabled: true, + }) + if err := svc.ConfirmReset(context.Background(), "tok", "short"); !errors.Is(err, reset.ErrWeakPassword) { + t.Fatalf("got %v", err) + } +} + +func TestRequestReset_MailFailureRollsBackToken(t *testing.T) { + u := &users.User{ID: "u1", Email: "alice@example.com"} + tokens := &memTokens{} + mail := &memMailer{err: errors.New("smtp down")} + svc := reset.NewService(newMemUsers(u), tokens, mail, reset.Config{ + PublicBaseURL: "https://grom.example.com", + TokenTTL: time.Hour, + Enabled: true, + }) + + err := svc.RequestReset(context.Background(), "alice@example.com") + if err == nil { + t.Fatal("expected mailer error") + } + tokens.mu.Lock() + n := len(tokens.byHash) + tokens.mu.Unlock() + if n != 0 { + t.Fatalf("expected token rollback, got %d tokens", n) + } +} + +func TestRequestReset_ReplacesPreviousToken(t *testing.T) { + hash, err := auth.HashPassword("oldpassword") + if err != nil { + t.Fatal(err) + } + u := &users.User{ID: "u1", Email: "alice@example.com", PasswordHash: hash} + tokens := &memTokens{} + mail := &memMailer{} + svc := reset.NewService(newMemUsers(u), tokens, mail, reset.Config{ + PublicBaseURL: "https://grom.example.com/", + TokenTTL: time.Hour, + Enabled: true, + }) + + if err := svc.RequestReset(context.Background(), "alice@example.com"); err != nil { + t.Fatal(err) + } + first := tokenFromMail(t, mail.last()) + if !strings.Contains(mail.last().Text, "https://grom.example.com/reset-password?token=") { + t.Fatalf("expected trimmed base URL in link: %s", mail.last().Text) + } + + if err := svc.RequestReset(context.Background(), "alice@example.com"); err != nil { + t.Fatal(err) + } + second := tokenFromMail(t, mail.last()) + if first == second { + t.Fatal("expected a new token on second request") + } + + if err := svc.ConfirmReset(context.Background(), first, "newpassword"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("old token: %v", err) + } + if err := svc.ConfirmReset(context.Background(), second, "newpassword"); err != nil { + t.Fatal(err) + } +} + +func TestConfirmReset_ExpiredToken(t *testing.T) { + u := &users.User{ID: "u1", Email: "alice@example.com"} + tokens := &memTokens{} + mail := &memMailer{} + svc := reset.NewService(newMemUsers(u), tokens, mail, reset.Config{ + PublicBaseURL: "https://grom.example.com", + TokenTTL: time.Hour, + Enabled: true, + }) + if err := svc.RequestReset(context.Background(), "alice@example.com"); err != nil { + t.Fatal(err) + } + raw := tokenFromMail(t, mail.last()) + + tokens.mu.Lock() + for h, rec := range tokens.byHash { + rec.ExpiresAt = time.Now().UTC().Add(-time.Minute) + tokens.byHash[h] = rec + } + tokens.mu.Unlock() + + if err := svc.ConfirmReset(context.Background(), raw, "newpassword"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("got %v", err) + } +} + +func TestService_NotConfigured(t *testing.T) { + svc := reset.NewService(newMemUsers(&users.User{ID: "u1", Email: "a@b.c"}), &memTokens{}, &memMailer{}, reset.Config{ + PublicBaseURL: "http://localhost", + Enabled: false, + }) + if err := svc.RequestReset(context.Background(), "a@b.c"); !errors.Is(err, reset.ErrNotConfigured) { + t.Fatalf("request: %v", err) + } + if err := svc.ConfirmReset(context.Background(), "tok", "password12"); !errors.Is(err, reset.ErrNotConfigured) { + t.Fatalf("confirm: %v", err) + } +} + +func TestRequestReset_EmptyEmail(t *testing.T) { + mail := &memMailer{} + svc := reset.NewService(newMemUsers(&users.User{ID: "u1", Email: "a@b.c"}), &memTokens{}, mail, reset.Config{ + PublicBaseURL: "http://localhost", + Enabled: true, + }) + if err := svc.RequestReset(context.Background(), " "); err != nil { + t.Fatal(err) + } + if mail.last().Subject != "" { + t.Fatal("expected no mail for empty email") + } +} + +func TestConfirmReset_EmptyToken(t *testing.T) { + svc := reset.NewService(newMemUsers(&users.User{ID: "u1", Email: "a@b.c"}), &memTokens{}, &memMailer{}, reset.Config{ + PublicBaseURL: "http://localhost", + Enabled: true, + }) + if err := svc.ConfirmReset(context.Background(), " ", "password12"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("got %v", err) + } +} + +func tokenFromMail(t *testing.T, msg mailer.Message) string { + t.Helper() + idx := strings.Index(msg.Text, "token=") + if idx < 0 { + t.Fatalf("token missing in text: %s", msg.Text) + } + raw := strings.TrimSpace(msg.Text[idx+len("token="):]) + if i := strings.IndexAny(raw, "\n\r "); i >= 0 { + raw = raw[:i] + } + if raw == "" { + t.Fatal("empty token") + } + return raw +} + +func TestLimiterForgot(t *testing.T) { + l := reset.NewLimiter() + for i := 0; i < 3; i++ { + ok, _ := l.AllowForgot("1.2.3.4", "a@b.c") + if !ok { + t.Fatalf("request %d limited", i) + } + } + ok, retry := l.AllowForgot("1.2.3.4", "a@b.c") + if ok || retry <= 0 { + t.Fatalf("expected email limit, ok=%v retry=%v", ok, retry) + } +} + +func TestLimiterForgotIP(t *testing.T) { + l := reset.NewLimiter() + for i := 0; i < 10; i++ { + ok, _ := l.AllowForgot("9.9.9.9", fmt.Sprintf("user%d@example.com", i)) + if !ok { + t.Fatalf("request %d limited", i) + } + } + ok, retry := l.AllowForgot("9.9.9.9", "other@example.com") + if ok || retry <= 0 { + t.Fatalf("expected IP limit, ok=%v retry=%v", ok, retry) + } + ok, _ = l.AllowForgot("8.8.8.8", "other@example.com") + if !ok { + t.Fatal("different IP should be allowed") + } +} + +func TestLimiterReset(t *testing.T) { + l := reset.NewLimiter() + for i := 0; i < 20; i++ { + ok, _ := l.AllowReset("1.2.3.4") + if !ok { + t.Fatalf("request %d limited", i) + } + } + ok, retry := l.AllowReset("1.2.3.4") + if ok || retry <= 0 { + t.Fatalf("expected reset IP limit, ok=%v retry=%v", ok, retry) + } + ok, _ = l.AllowReset("5.5.5.5") + if !ok { + t.Fatal("different IP should be allowed") + } +} diff --git a/internal/auth/reset/store.go b/internal/auth/reset/store.go new file mode 100644 index 0000000..cb16b15 --- /dev/null +++ b/internal/auth/reset/store.go @@ -0,0 +1,22 @@ +package reset + +import "time" + +// TokenRecord is a stored password-reset token (hash only). +type TokenRecord struct { + TokenHash string `yaml:"token_hash" json:"token_hash"` + UserID string `yaml:"user_id" json:"user_id"` + ExpiresAt time.Time `yaml:"expires_at" json:"expires_at"` + CreatedAt time.Time `yaml:"created_at" json:"created_at"` +} + +// TokenStore persists password-reset tokens. +type TokenStore interface { + // ReplaceForUser deletes any existing tokens for userID, then stores record. + ReplaceForUser(userID string, record TokenRecord) error + // GetByHash returns a valid (non-expired) record, or ErrInvalidToken. + // Expired records are deleted lazily. + GetByHash(hash string) (*TokenRecord, error) + // DeleteByHash removes a token by its hash. Missing keys are not an error. + DeleteByHash(hash string) error +} diff --git a/internal/config/config.go b/internal/config/config.go index 82c0bf2..fea04d9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,7 +67,14 @@ type Config struct { Auth struct { JWTSecret string `mapstructure:"jwt_secret" yaml:"jwt_secret"` JWTTTLHours int `mapstructure:"jwt_ttl_hours" yaml:"jwt_ttl_hours"` + Reset struct { + // PublicBaseURL is the instance base URL used in password-reset email links (no trailing slash). + PublicBaseURL string `mapstructure:"public_base_url" yaml:"public_base_url"` + // TokenTTLMinutes is how long a reset token remains valid. Default: 60. + TokenTTLMinutes int `mapstructure:"token_ttl_minutes" yaml:"token_ttl_minutes"` + } `mapstructure:"reset" yaml:"reset"` } `mapstructure:"auth" yaml:"auth"` + Mailer MailerConfig `mapstructure:"mailer" yaml:"mailer"` Federation struct { Enabled bool `mapstructure:"enabled" yaml:"enabled"` Domain string `mapstructure:"domain" yaml:"domain"` @@ -92,6 +99,50 @@ type LoggingConfig struct { Format string `mapstructure:"format" yaml:"format"` } +// MailerDriver selects how outbound email is delivered. +type MailerDriver string + +const ( + MailerDriverOff MailerDriver = "off" + MailerDriverLog MailerDriver = "log" + MailerDriverSMTP MailerDriver = "smtp" +) + +// MailerEncryption selects SMTP transport security. +type MailerEncryption string + +const ( + MailerEncryptionSTARTTLS MailerEncryption = "starttls" + MailerEncryptionTLS MailerEncryption = "tls" + MailerEncryptionNone MailerEncryption = "none" +) + +// MailerConfig controls outbound email (password reset and future notifications). +type MailerConfig struct { + // Driver is off, log, or smtp. Default: off. + Driver string `mapstructure:"driver" yaml:"driver"` + // From is the sender address, e.g. "Grom ". + From string `mapstructure:"from" yaml:"from"` + SMTP struct { + Host string `mapstructure:"host" yaml:"host"` + Port int `mapstructure:"port" yaml:"port"` + Username string `mapstructure:"username" yaml:"username"` + Password string `mapstructure:"password" yaml:"password"` + Encryption string `mapstructure:"encryption" yaml:"encryption"` // starttls | tls | none + } `mapstructure:"smtp" yaml:"smtp"` +} + +// MailerEnabled reports whether outbound email delivery is configured. +func (c *Config) MailerEnabled() bool { + d := strings.ToLower(strings.TrimSpace(c.Mailer.Driver)) + return d == string(MailerDriverLog) || d == string(MailerDriverSMTP) +} + +// PasswordResetEnabled reports whether password reset via email is available. +func (c *Config) PasswordResetEnabled() bool { + return c.MailerEnabled() && strings.TrimSpace(c.Auth.Reset.PublicBaseURL) != "" +} + var Cfg Config func GetConfig(configPath string) { @@ -125,6 +176,13 @@ func FinalizeConfig(cfg *Config) error { if cfg.Auth.JWTTTLHours <= 0 { cfg.Auth.JWTTTLHours = 24 } + if cfg.Auth.Reset.TokenTTLMinutes <= 0 { + cfg.Auth.Reset.TokenTTLMinutes = 60 + } + cfg.Auth.Reset.PublicBaseURL = strings.TrimRight(strings.TrimSpace(cfg.Auth.Reset.PublicBaseURL), "/") + if err := finalizeMailer(&cfg.Mailer, cfg.Auth.Reset.PublicBaseURL); err != nil { + return err + } if cfg.Federation.DeliveryWorkers <= 0 { cfg.Federation.DeliveryWorkers = 2 } @@ -280,6 +338,59 @@ func FinalizeConfig(cfg *Config) error { return nil } +func finalizeMailer(cfg *MailerConfig, publicBaseURL string) error { + driver := strings.ToLower(strings.TrimSpace(cfg.Driver)) + if driver == "" { + driver = string(MailerDriverOff) + } + switch MailerDriver(driver) { + case MailerDriverOff, MailerDriverLog, MailerDriverSMTP: + cfg.Driver = driver + default: + return fmt.Errorf("mailer.driver must be one of off, log, smtp (got %q)", cfg.Driver) + } + + if MailerDriver(driver) == MailerDriverOff { + return nil + } + + cfg.From = strings.TrimSpace(cfg.From) + if cfg.From == "" { + return fmt.Errorf("mailer.from is required when mailer.driver is %s", driver) + } + if publicBaseURL == "" { + return fmt.Errorf("auth.reset.public_base_url is required when mailer.driver is %s", driver) + } + + if MailerDriver(driver) != MailerDriverSMTP { + return nil + } + + cfg.SMTP.Host = strings.TrimSpace(cfg.SMTP.Host) + if cfg.SMTP.Host == "" { + return fmt.Errorf("mailer.smtp.host is required when mailer.driver is smtp") + } + if cfg.SMTP.Port <= 0 { + return fmt.Errorf("mailer.smtp.port is required when mailer.driver is smtp") + } + + enc := strings.ToLower(strings.TrimSpace(cfg.SMTP.Encryption)) + if enc == "" { + if cfg.SMTP.Port == 465 { + enc = string(MailerEncryptionTLS) + } else { + enc = string(MailerEncryptionSTARTTLS) + } + } + switch MailerEncryption(enc) { + case MailerEncryptionSTARTTLS, MailerEncryptionTLS, MailerEncryptionNone: + cfg.SMTP.Encryption = enc + default: + return fmt.Errorf("mailer.smtp.encryption must be one of starttls, tls, none (got %q)", cfg.SMTP.Encryption) + } + return nil +} + func finalizeLogging(cfg *LoggingConfig) error { level := strings.ToLower(strings.TrimSpace(cfg.Level)) if level == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 196ceec..950615c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -309,6 +309,90 @@ func TestFinalizeConfig_LoggingInvalid(t *testing.T) { } } +func TestFinalizeConfig_MailerDefaultsOff(t *testing.T) { + cfg := baseCfg() + if err := config.FinalizeConfig(&cfg); err != nil { + t.Fatal(err) + } + if cfg.Mailer.Driver != "off" { + t.Fatalf("driver = %q", cfg.Mailer.Driver) + } + if cfg.PasswordResetEnabled() { + t.Fatal("expected password reset disabled") + } + if cfg.Auth.Reset.TokenTTLMinutes != 60 { + t.Fatalf("ttl = %d", cfg.Auth.Reset.TokenTTLMinutes) + } +} + +func TestFinalizeConfig_MailerLogRequiresFromAndBaseURL(t *testing.T) { + cfg := baseCfg() + cfg.Mailer.Driver = "log" + if err := config.FinalizeConfig(&cfg); err == nil { + t.Fatal("expected error") + } + cfg.Mailer.From = "Grom " + if err := config.FinalizeConfig(&cfg); err == nil { + t.Fatal("expected public_base_url error") + } + cfg.Auth.Reset.PublicBaseURL = "https://grom.example.com/" + if err := config.FinalizeConfig(&cfg); err != nil { + t.Fatal(err) + } + if cfg.Auth.Reset.PublicBaseURL != "https://grom.example.com" { + t.Fatalf("base url = %q", cfg.Auth.Reset.PublicBaseURL) + } + if !cfg.PasswordResetEnabled() { + t.Fatal("expected enabled") + } +} + +func TestFinalizeConfig_MailerSMTP(t *testing.T) { + cfg := baseCfg() + cfg.Mailer.Driver = "smtp" + cfg.Mailer.From = "noreply@example.com" + cfg.Auth.Reset.PublicBaseURL = "https://example.com" + cfg.Mailer.SMTP.Host = "smtp.example.com" + cfg.Mailer.SMTP.Port = 587 + if err := config.FinalizeConfig(&cfg); err != nil { + t.Fatal(err) + } + if cfg.Mailer.SMTP.Encryption != "starttls" { + t.Fatalf("encryption = %q", cfg.Mailer.SMTP.Encryption) + } +} + +func TestFinalizeConfig_MailerInvalidDriver(t *testing.T) { + cfg := baseCfg() + cfg.Mailer.Driver = "ses" + if err := config.FinalizeConfig(&cfg); err == nil { + t.Fatal("expected error") + } +} + +func TestFinalizeConfig_MailerSMTPValidation(t *testing.T) { + cfg := baseCfg() + cfg.Mailer.Driver = "smtp" + cfg.Mailer.From = "noreply@example.com" + cfg.Auth.Reset.PublicBaseURL = "https://example.com" + if err := config.FinalizeConfig(&cfg); err == nil { + t.Fatal("expected host error") + } + cfg.Mailer.SMTP.Host = "smtp.example.com" + if err := config.FinalizeConfig(&cfg); err == nil { + t.Fatal("expected port error") + } + cfg.Mailer.SMTP.Port = 587 + cfg.Mailer.SMTP.Encryption = "ssl" + if err := config.FinalizeConfig(&cfg); err == nil { + t.Fatal("expected encryption error") + } + cfg.Mailer.SMTP.Encryption = "tls" + if err := config.FinalizeConfig(&cfg); err != nil { + t.Fatal(err) + } +} + func TestHostWithoutPort(t *testing.T) { if got := config.HostWithoutPort("192.168.1.251:8443"); got != "192.168.1.251" { t.Fatalf("HostWithoutPort = %q", got) diff --git a/internal/federation/inbox_flow_test.go b/internal/federation/inbox_flow_test.go index 7765874..9de7438 100644 --- a/internal/federation/inbox_flow_test.go +++ b/internal/federation/inbox_flow_test.go @@ -48,6 +48,7 @@ func (m *memUsers) Create(string, string, string, string) (*users.User, error) { func (m *memUsers) UpdateProfile(string, string) (*users.User, error) { return nil, errors.New("not implemented") } +func (m *memUsers) UpdatePassword(string, string) error { return errors.New("not implemented") } func (m *memUsers) SetLastEquipmentForSport(string, string, []string) error { return nil } func (m *memUsers) RemoveEquipmentFromLastSets(string, string) error { return nil } func (m *memUsers) GetProfile(string) (*users.Profile, error) { diff --git a/internal/mailer/log.go b/internal/mailer/log.go new file mode 100644 index 0000000..cb545fa --- /dev/null +++ b/internal/mailer/log.go @@ -0,0 +1,23 @@ +package mailer + +import ( + "context" + "log/slog" + "strings" +) + +// Log writes messages to slog (for development). +type Log struct{} + +func (Log) Send(_ context.Context, msg Message) error { + if err := validateMessage(msg); err != nil { + return err + } + slog.Info("mail_send", + "to", strings.Join(msg.To, ","), + "subject", msg.Subject, + "text", msg.Text, + "html", msg.HTML, + ) + return nil +} diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..186a6dd --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,57 @@ +package mailer + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/solargate/grom/internal/config" +) + +var ( + // ErrDisabled is returned when mailer.driver is off. + ErrDisabled = errors.New("mailer is disabled") + // ErrEmptyMessage is returned when required message fields are missing. + ErrEmptyMessage = errors.New("mail message is incomplete") +) + +// Message is a transactional email payload. +type Message struct { + To []string + Subject string + Text string + HTML string +} + +// Mailer delivers outbound email. Implementations must be safe for concurrent use. +type Mailer interface { + Send(ctx context.Context, msg Message) error +} + +// New builds a Mailer from config. +func New(cfg config.MailerConfig) (Mailer, error) { + switch config.MailerDriver(strings.ToLower(strings.TrimSpace(cfg.Driver))) { + case config.MailerDriverOff, "": + return Nop{}, nil + case config.MailerDriverLog: + return Log{}, nil + case config.MailerDriverSMTP: + return newSMTP(cfg) + default: + return nil, fmt.Errorf("unknown mailer.driver %q", cfg.Driver) + } +} + +func validateMessage(msg Message) error { + if len(msg.To) == 0 || strings.TrimSpace(msg.To[0]) == "" { + return fmt.Errorf("%w: to is required", ErrEmptyMessage) + } + if strings.TrimSpace(msg.Subject) == "" { + return fmt.Errorf("%w: subject is required", ErrEmptyMessage) + } + if strings.TrimSpace(msg.Text) == "" { + return fmt.Errorf("%w: text body is required", ErrEmptyMessage) + } + return nil +} diff --git a/internal/mailer/mailer_test.go b/internal/mailer/mailer_test.go new file mode 100644 index 0000000..f0154fe --- /dev/null +++ b/internal/mailer/mailer_test.go @@ -0,0 +1,44 @@ +package mailer_test + +import ( + "context" + "errors" + "testing" + + "github.com/solargate/grom/internal/config" + "github.com/solargate/grom/internal/mailer" +) + +func TestNew_OffReturnsNop(t *testing.T) { + m, err := mailer.New(config.MailerConfig{Driver: "off"}) + if err != nil { + t.Fatal(err) + } + if err := m.Send(context.Background(), mailer.Message{ + To: []string{"a@b.c"}, Subject: "s", Text: "body", + }); !errors.Is(err, mailer.ErrDisabled) { + t.Fatalf("got %v", err) + } +} + +func TestNew_LogValidatesMessage(t *testing.T) { + m, err := mailer.New(config.MailerConfig{Driver: "log"}) + if err != nil { + t.Fatal(err) + } + if err := m.Send(context.Background(), mailer.Message{}); !errors.Is(err, mailer.ErrEmptyMessage) { + t.Fatalf("empty: %v", err) + } + if err := m.Send(context.Background(), mailer.Message{ + To: []string{"a@b.c"}, Subject: "hello", Text: "body", + }); err != nil { + t.Fatal(err) + } +} + +func TestNew_UnknownDriver(t *testing.T) { + _, err := mailer.New(config.MailerConfig{Driver: "ses"}) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/mailer/nop.go b/internal/mailer/nop.go new file mode 100644 index 0000000..0716862 --- /dev/null +++ b/internal/mailer/nop.go @@ -0,0 +1,10 @@ +package mailer + +import "context" + +// Nop is a disabled mailer. +type Nop struct{} + +func (Nop) Send(context.Context, Message) error { + return ErrDisabled +} diff --git a/internal/mailer/smtp.go b/internal/mailer/smtp.go new file mode 100644 index 0000000..456cab7 --- /dev/null +++ b/internal/mailer/smtp.go @@ -0,0 +1,69 @@ +package mailer + +import ( + "context" + "fmt" + "strings" + + "github.com/solargate/grom/internal/config" + "github.com/wneessen/go-mail" +) + +type smtpMailer struct { + client *mail.Client + from string +} + +func newSMTP(cfg config.MailerConfig) (Mailer, error) { + opts := []mail.Option{ + mail.WithPort(cfg.SMTP.Port), + } + user := strings.TrimSpace(cfg.SMTP.Username) + pass := cfg.SMTP.Password + if user != "" { + opts = append(opts, + mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), + mail.WithUsername(user), + mail.WithPassword(pass), + ) + } + + switch config.MailerEncryption(cfg.SMTP.Encryption) { + case config.MailerEncryptionTLS: + opts = append(opts, mail.WithSSL()) + case config.MailerEncryptionNone: + opts = append(opts, mail.WithTLSPortPolicy(mail.NoTLS)) + default: + opts = append(opts, mail.WithTLSPortPolicy(mail.TLSMandatory)) + } + + client, err := mail.NewClient(cfg.SMTP.Host, opts...) + if err != nil { + return nil, fmt.Errorf("create smtp mailer: %w", err) + } + return &smtpMailer{client: client, from: cfg.From}, nil +} + +func (m *smtpMailer) Send(ctx context.Context, msg Message) error { + if err := validateMessage(msg); err != nil { + return err + } + + mmsg := mail.NewMsg() + if err := mmsg.From(m.from); err != nil { + return fmt.Errorf("set from: %w", err) + } + if err := mmsg.To(msg.To...); err != nil { + return fmt.Errorf("set to: %w", err) + } + mmsg.Subject(msg.Subject) + mmsg.SetBodyString(mail.TypeTextPlain, msg.Text) + if html := strings.TrimSpace(msg.HTML); html != "" { + mmsg.AddAlternativeString(mail.TypeTextHTML, html) + } + + if err := m.client.DialAndSendWithContext(ctx, mmsg); err != nil { + return fmt.Errorf("smtp send: %w", err) + } + return nil +} diff --git a/internal/storage/backend.go b/internal/storage/backend.go index c7034f0..7b8c751 100644 --- a/internal/storage/backend.go +++ b/internal/storage/backend.go @@ -5,6 +5,7 @@ import ( "github.com/solargate/grom/internal/equipment" "github.com/solargate/grom/internal/federation" + "github.com/solargate/grom/internal/auth/reset" "github.com/solargate/grom/internal/social" "github.com/solargate/grom/internal/storage/blob" "github.com/solargate/grom/internal/users" @@ -20,6 +21,7 @@ type Backend interface { Social() social.Repository Federation() federation.Storage Blobs() blob.Store + ResetTokens() reset.TokenStore Close() error Ping(ctx context.Context) error diff --git a/internal/storage/bbolt/backend.go b/internal/storage/bbolt/backend.go index 6c1c012..30cd0a7 100644 --- a/internal/storage/bbolt/backend.go +++ b/internal/storage/bbolt/backend.go @@ -8,6 +8,7 @@ import ( bolt "go.etcd.io/bbolt" + "github.com/solargate/grom/internal/auth/reset" "github.com/solargate/grom/internal/equipment" "github.com/solargate/grom/internal/federation" "github.com/solargate/grom/internal/social" @@ -29,6 +30,7 @@ type Backend struct { social *SocialStore fed federation.Storage blobs blob.Store + resetTokens reset.TokenStore } // Open opens a bbolt metadata database and filesystem blob store under location. @@ -82,6 +84,7 @@ func Open(dbPath, location string) (*Backend, error) { social: socialStore, fed: federation.NewStorage(followersStore, inboxStore), blobs: blobStore, + resetTokens: NewResetTokenStore(db), }, nil } @@ -111,6 +114,7 @@ func (b *Backend) Equipment() equipment.Repository { return b.equipment } func (b *Backend) Social() social.Repository { return b.social } func (b *Backend) Federation() federation.Storage { return b.fed } func (b *Backend) Blobs() blob.Store { return b.blobs } +func (b *Backend) ResetTokens() reset.TokenStore { return b.resetTokens } func (b *Backend) DB() *bolt.DB { return b.db } func (b *Backend) Location() string { return b.location } diff --git a/internal/storage/bbolt/buckets.go b/internal/storage/bbolt/buckets.go index 92416ab..bca567a 100644 --- a/internal/storage/bbolt/buckets.go +++ b/internal/storage/bbolt/buckets.go @@ -27,6 +27,7 @@ var ( bucketFedSpeedCharts = []byte("fed_speed_charts") bucketHeartRateCharts = []byte("heart_rate_charts") bucketFedHeartRateCharts = []byte("fed_heart_rate_charts") + bucketResetTokens = []byte("reset_tokens") ) var allBuckets = [][]byte{ @@ -56,6 +57,7 @@ var allBuckets = [][]byte{ bucketFedSpeedCharts, bucketHeartRateCharts, bucketFedHeartRateCharts, + bucketResetTokens, } const schemaVersion = "1" diff --git a/internal/storage/bbolt/reset_tokens.go b/internal/storage/bbolt/reset_tokens.go new file mode 100644 index 0000000..ddf1df4 --- /dev/null +++ b/internal/storage/bbolt/reset_tokens.go @@ -0,0 +1,80 @@ +package bbolt + +import ( + "encoding/json" + "fmt" + "time" + + bolt "go.etcd.io/bbolt" + + "github.com/solargate/grom/internal/auth/reset" +) + +// ResetTokenStore persists password-reset tokens in bbolt. +type ResetTokenStore struct { + db *bolt.DB +} + +func NewResetTokenStore(db *bolt.DB) *ResetTokenStore { + return &ResetTokenStore{db: db} +} + +func (s *ResetTokenStore) ReplaceForUser(userID string, record reset.TokenRecord) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketResetTokens) + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var existing reset.TokenRecord + if err := json.Unmarshal(v, &existing); err != nil { + return err + } + if existing.UserID == userID { + if err := b.Delete(k); err != nil { + return err + } + } + } + payload, err := json.Marshal(record) + if err != nil { + return err + } + return b.Put([]byte(record.TokenHash), payload) + }) +} + +func (s *ResetTokenStore) GetByHash(hash string) (*reset.TokenRecord, error) { + var found *reset.TokenRecord + err := s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketResetTokens) + raw := b.Get([]byte(hash)) + if raw == nil { + return reset.ErrInvalidToken + } + var rec reset.TokenRecord + if err := json.Unmarshal(raw, &rec); err != nil { + return err + } + if !rec.ExpiresAt.After(time.Now().UTC()) { + _ = b.Delete([]byte(hash)) + return reset.ErrInvalidToken + } + found = &rec + return nil + }) + if err != nil { + return nil, err + } + return found, nil +} + +func (s *ResetTokenStore) DeleteByHash(hash string) error { + return s.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucketResetTokens) + if err := b.Delete([]byte(hash)); err != nil { + return fmt.Errorf("delete reset token: %w", err) + } + return nil + }) +} + +var _ reset.TokenStore = (*ResetTokenStore)(nil) diff --git a/internal/storage/bbolt/reset_tokens_test.go b/internal/storage/bbolt/reset_tokens_test.go new file mode 100644 index 0000000..bf7188e --- /dev/null +++ b/internal/storage/bbolt/reset_tokens_test.go @@ -0,0 +1,57 @@ +package bbolt_test + +import ( + "errors" + "testing" + "time" + + "github.com/solargate/grom/internal/auth/reset" +) + +func TestResetTokenStore(t *testing.T) { + b := openTestBackend(t) + store := b.ResetTokens() + now := time.Now().UTC() + rec := reset.TokenRecord{ + TokenHash: "abc", + UserID: "u1", + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, + } + if err := store.ReplaceForUser("u1", rec); err != nil { + t.Fatal(err) + } + got, err := store.GetByHash("abc") + if err != nil || got.UserID != "u1" { + t.Fatalf("get: %#v %v", got, err) + } + rec2 := rec + rec2.TokenHash = "def" + if err := store.ReplaceForUser("u1", rec2); err != nil { + t.Fatal(err) + } + if _, err := store.GetByHash("abc"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("old token: %v", err) + } + expired := rec2 + expired.TokenHash = "exp" + expired.ExpiresAt = now.Add(-time.Minute) + if err := store.ReplaceForUser("u1", expired); err != nil { + t.Fatal(err) + } + if _, err := store.GetByHash("exp"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("expired: %v", err) + } + + rec3 := rec + rec3.TokenHash = "to-delete" + if err := store.ReplaceForUser("u2", rec3); err != nil { + t.Fatal(err) + } + if err := store.DeleteByHash("to-delete"); err != nil { + t.Fatal(err) + } + if _, err := store.GetByHash("to-delete"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("after delete: %v", err) + } +} diff --git a/internal/storage/bbolt/users.go b/internal/storage/bbolt/users.go index 4496b15..bc2fbdc 100644 --- a/internal/storage/bbolt/users.go +++ b/internal/storage/bbolt/users.go @@ -235,6 +235,21 @@ func (s *UsersStore) UpdateProfile(userID, name string) (*users.User, error) { return result, err } +func (s *UsersStore) UpdatePassword(userID, passwordHash string) error { + passwordHash = strings.TrimSpace(passwordHash) + if passwordHash == "" { + return fmt.Errorf("password hash is required") + } + return s.db.Update(func(tx *bolt.Tx) error { + u, err := s.getByID(tx, userID) + if err != nil { + return err + } + u.PasswordHash = passwordHash + return s.putUser(tx, *u) + }) +} + // PutExisting writes a user record without hashing (used by migration). func (s *UsersStore) PutExisting(u users.User) error { if err := ensureUserDir(s.dataDir, u.Nickname); err != nil { diff --git a/internal/storage/file/backend.go b/internal/storage/file/backend.go index aa8a8dd..39f50ae 100644 --- a/internal/storage/file/backend.go +++ b/internal/storage/file/backend.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/solargate/grom/internal/auth/reset" "github.com/solargate/grom/internal/equipment" "github.com/solargate/grom/internal/federation" "github.com/solargate/grom/internal/social" @@ -24,6 +25,7 @@ type Backend struct { social social.Repository fed federation.Storage blobs blob.Store + resetTokens reset.TokenStore } func Open(location string) (*Backend, error) { @@ -66,6 +68,7 @@ func Open(location string) (*Backend, error) { social: socialStore, fed: federation.NewStorage(followersStore, inboxStore), blobs: blobStore, + resetTokens: NewResetTokenStore(location), }, nil } @@ -78,6 +81,7 @@ func (b *Backend) Equipment() equipment.Repository { return b.equipment } func (b *Backend) Social() social.Repository { return b.social } func (b *Backend) Federation() federation.Storage { return b.fed } func (b *Backend) Blobs() blob.Store { return b.blobs } +func (b *Backend) ResetTokens() reset.TokenStore { return b.resetTokens } func (b *Backend) Close() error { return nil } diff --git a/internal/storage/file/reset_tokens.go b/internal/storage/file/reset_tokens.go new file mode 100644 index 0000000..d92ecd8 --- /dev/null +++ b/internal/storage/file/reset_tokens.go @@ -0,0 +1,133 @@ +package file + +import ( + "os" + "path/filepath" + "sync" + "time" + + "github.com/solargate/grom/internal/auth/reset" + "gopkg.in/yaml.v3" +) + +const resetTokensFileName = "reset_tokens.yaml" + +type resetTokensFile struct { + Tokens []reset.TokenRecord `yaml:"tokens"` +} + +// ResetTokenStore persists password-reset tokens as YAML. +type ResetTokenStore struct { + path string + mu sync.Mutex +} + +func NewResetTokenStore(dataDir string) *ResetTokenStore { + return &ResetTokenStore{path: filepath.Join(dataDir, resetTokensFileName)} +} + +func (s *ResetTokenStore) ReplaceForUser(userID string, record reset.TokenRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + + tokens, err := s.load() + if err != nil { + return err + } + out := tokens[:0] + for _, t := range tokens { + if t.UserID == userID { + continue + } + out = append(out, t) + } + out = append(out, record) + return s.save(out) +} + +func (s *ResetTokenStore) GetByHash(hash string) (*reset.TokenRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + + tokens, err := s.load() + if err != nil { + return nil, err + } + now := time.Now().UTC() + changed := false + var found *reset.TokenRecord + out := make([]reset.TokenRecord, 0, len(tokens)) + for i := range tokens { + t := tokens[i] + if t.TokenHash != hash { + out = append(out, t) + continue + } + if !t.ExpiresAt.After(now) { + changed = true + continue + } + cp := t + found = &cp + out = append(out, t) + } + if changed { + if err := s.save(out); err != nil { + return nil, err + } + } + if found == nil { + return nil, reset.ErrInvalidToken + } + return found, nil +} + +func (s *ResetTokenStore) DeleteByHash(hash string) error { + s.mu.Lock() + defer s.mu.Unlock() + + tokens, err := s.load() + if err != nil { + return err + } + out := tokens[:0] + for _, t := range tokens { + if t.TokenHash == hash { + continue + } + out = append(out, t) + } + return s.save(out) +} + +func (s *ResetTokenStore) load() ([]reset.TokenRecord, error) { + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var file resetTokensFile + if err := yaml.Unmarshal(data, &file); err != nil { + return nil, err + } + return file.Tokens, nil +} + +func (s *ResetTokenStore) save(tokens []reset.TokenRecord) error { + if tokens == nil { + tokens = []reset.TokenRecord{} + } + data, err := yaml.Marshal(resetTokensFile{Tokens: tokens}) + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +var _ reset.TokenStore = (*ResetTokenStore)(nil) diff --git a/internal/storage/file/reset_tokens_test.go b/internal/storage/file/reset_tokens_test.go new file mode 100644 index 0000000..d3f89ae --- /dev/null +++ b/internal/storage/file/reset_tokens_test.go @@ -0,0 +1,58 @@ +package file_test + +import ( + "errors" + "testing" + "time" + + "github.com/solargate/grom/internal/auth/reset" + "github.com/solargate/grom/internal/storage/file" +) + +func TestResetTokenStore(t *testing.T) { + dir := t.TempDir() + store := file.NewResetTokenStore(dir) + now := time.Now().UTC() + rec := reset.TokenRecord{ + TokenHash: "abc", + UserID: "u1", + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, + } + if err := store.ReplaceForUser("u1", rec); err != nil { + t.Fatal(err) + } + got, err := store.GetByHash("abc") + if err != nil || got.UserID != "u1" { + t.Fatalf("get: %#v %v", got, err) + } + rec2 := rec + rec2.TokenHash = "def" + if err := store.ReplaceForUser("u1", rec2); err != nil { + t.Fatal(err) + } + if _, err := store.GetByHash("abc"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("old token: %v", err) + } + expired := rec2 + expired.TokenHash = "exp" + expired.ExpiresAt = now.Add(-time.Minute) + if err := store.ReplaceForUser("u1", expired); err != nil { + t.Fatal(err) + } + if _, err := store.GetByHash("exp"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("expired: %v", err) + } + + rec3 := rec + rec3.TokenHash = "to-delete" + if err := store.ReplaceForUser("u2", rec3); err != nil { + t.Fatal(err) + } + if err := store.DeleteByHash("to-delete"); err != nil { + t.Fatal(err) + } + if _, err := store.GetByHash("to-delete"); !errors.Is(err, reset.ErrInvalidToken) { + t.Fatalf("after delete: %v", err) + } +} diff --git a/internal/storage/file/users.go b/internal/storage/file/users.go index e072abc..92e77aa 100644 --- a/internal/storage/file/users.go +++ b/internal/storage/file/users.go @@ -256,6 +256,24 @@ func (s *UsersStore) UpdateProfile(userID, name string) (*users.User, error) { return nil, users.ErrUserNotFound } +func (s *UsersStore) UpdatePassword(userID, passwordHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + + passwordHash = strings.TrimSpace(passwordHash) + if passwordHash == "" { + return fmt.Errorf("password hash is required") + } + for i := range s.users { + if s.users[i].ID != userID { + continue + } + s.users[i].PasswordHash = passwordHash + return s.save() + } + return users.ErrUserNotFound +} + // Import writes a user record as-is (used by storage migration). func (s *UsersStore) Import(user users.User) error { s.mu.Lock() diff --git a/internal/users/repository.go b/internal/users/repository.go index 55e3f5a..fd81668 100644 --- a/internal/users/repository.go +++ b/internal/users/repository.go @@ -8,6 +8,7 @@ type Repository interface { ListAll() ([]User, error) Create(nickname, name, email, password string) (*User, error) UpdateProfile(userID, name string) (*User, error) + UpdatePassword(userID, passwordHash string) error GetProfile(userID string) (*Profile, error) PutProfile(userID string, profile Profile) error diff --git a/ui/grom/lib/api_request.dart b/ui/grom/lib/api_request.dart index a7e1c96..9372ca4 100644 --- a/ui/grom/lib/api_request.dart +++ b/ui/grom/lib/api_request.dart @@ -25,15 +25,18 @@ class ServerInfo { ServerInfo({ required this.name, this.federationEnabled = false, + this.passwordResetEnabled = false, }); final String name; final bool federationEnabled; + final bool passwordResetEnabled; factory ServerInfo.fromJson(Map json) { return ServerInfo( name: json['name'] as String? ?? 'Grom Home', federationEnabled: json['federation_enabled'] as bool? ?? false, + passwordResetEnabled: json['password_reset_enabled'] as bool? ?? false, ); } } @@ -133,6 +136,40 @@ class ApiRequest { return ServerInfo(name: 'Grom Home'); } + Future forgotPassword({required String email}) async { + final response = await _client.post( + _uri('/api/v1/auth/password/forgot'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'email': email}), + ); + + if (response.statusCode == 204) { + return; + } + + throw _parseError(response); + } + + Future resetPassword({ + required String token, + required String password, + }) async { + final response = await _client.post( + _uri('/api/v1/auth/password/reset'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'token': token, + 'password': password, + }), + ); + + if (response.statusCode == 204) { + return; + } + + throw _parseError(response); + } + Future register({ required String nickname, required String name, diff --git a/ui/grom/lib/forgot_password.dart b/ui/grom/lib/forgot_password.dart new file mode 100644 index 0000000..8a4faac --- /dev/null +++ b/ui/grom/lib/forgot_password.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:grom/l10n/app_localizations.dart'; + +import 'api_request.dart'; +import 'platform/is_mobile_client.dart'; +import 'server_storage.dart'; +import 'server_url_resolver.dart'; +import 'widgets/server_url_field.dart'; + +class ForgotPasswordPage extends StatelessWidget { + const ForgotPasswordPage({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Scaffold( + appBar: AppBar(title: Text(l10n.forgotPasswordTitle)), + body: const SingleChildScrollView( + padding: EdgeInsets.all(24), + child: ForgotPasswordForm(), + ), + ); + } +} + +class ForgotPasswordForm extends StatefulWidget { + const ForgotPasswordForm({super.key}); + + @override + State createState() => _ForgotPasswordFormState(); +} + +class _ForgotPasswordFormState extends State { + final _formKey = GlobalKey(); + final _api = ApiRequest(); + final _emailController = TextEditingController(); + final _serverUrlController = TextEditingController(); + bool _isSubmitting = false; + + @override + void initState() { + super.initState(); + if (isMobileClient) { + _loadSavedServerUrl(); + } + } + + Future _loadSavedServerUrl() async { + final url = await ServerStorage.getBaseUrl(); + if (url != null && mounted) { + _serverUrlController.text = url; + } + } + + @override + void dispose() { + _emailController.dispose(); + _serverUrlController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) { + return; + } + + setState(() => _isSubmitting = true); + final l10n = AppLocalizations.of(context)!; + + try { + if (isMobileClient) { + final resolved = await resolveServerBaseUrl(_serverUrlController.text); + if (mounted) { + _serverUrlController.text = resolved; + } + await ServerStorage.saveBaseUrl(resolved); + } + + await _api.forgotPassword(email: _emailController.text.trim()); + + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.forgotPasswordCheckEmail)), + ); + Navigator.pop(context); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.forgotPasswordFailed)), + ); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.forgotPasswordHint), + const SizedBox(height: 16), + if (isMobileClient) ...[ + ServerUrlField(controller: _serverUrlController), + const SizedBox(height: 16), + ], + TextFormField( + controller: _emailController, + decoration: InputDecoration( + labelText: l10n.emailLabel, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return l10n.enterEmail; + } + final email = value.trim(); + if (!email.contains('@') || !email.contains('.')) { + return l10n.enterValidEmail; + } + return null; + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.forgotPasswordSubmit), + ), + ], + ), + ); + } +} diff --git a/ui/grom/lib/l10n/app_de.arb b/ui/grom/lib/l10n/app_de.arb index 07ca36c..c93e0df 100644 --- a/ui/grom/lib/l10n/app_de.arb +++ b/ui/grom/lib/l10n/app_de.arb @@ -41,6 +41,18 @@ "confirmPasswordLabel": "Passwort bestätigen *", "confirmPassword": "Passwort bestätigen", "passwordsDoNotMatch": "Passwörter stimmen nicht überein", + "forgotPasswordLink": "Passwort vergessen?", + "forgotPasswordTitle": "Passwort zurücksetzen", + "forgotPasswordHint": "Geben Sie die E-Mail Ihres Kontos ein. Falls sie registriert ist, senden wir einen Reset-Link. Öffnen Sie den Link im Browser, um ein neues Passwort zu wählen.", + "forgotPasswordSubmit": "Reset-Link senden", + "forgotPasswordCheckEmail": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet. Öffnen Sie ihn im Browser und melden Sie sich danach hier an.", + "forgotPasswordFailed": "Passwort-Reset konnte nicht angefordert werden", + "resetPasswordTitle": "Neues Passwort wählen", + "resetPasswordHint": "Geben Sie ein neues Passwort für Ihr Konto ein.", + "resetPasswordSubmit": "Passwort speichern", + "resetPasswordSuccess": "Passwort aktualisiert. Bitte melden Sie sich an.", + "resetPasswordFailed": "Passwort konnte nicht zurückgesetzt werden", + "resetPasswordInvalidToken": "Dieser Reset-Link fehlt oder ist ungültig.", "serverUrlLabel": "Server-URL *", "enterServerUrl": "Server-URL eingeben", "enterValidServerUrl": "Gültigen Server-Host oder URL eingeben", diff --git a/ui/grom/lib/l10n/app_en.arb b/ui/grom/lib/l10n/app_en.arb index 08ca349..b1d71eb 100644 --- a/ui/grom/lib/l10n/app_en.arb +++ b/ui/grom/lib/l10n/app_en.arb @@ -41,6 +41,18 @@ "confirmPasswordLabel": "Confirm password *", "confirmPassword": "Confirm password", "passwordsDoNotMatch": "Passwords do not match", + "forgotPasswordLink": "Forgot password?", + "forgotPasswordTitle": "Reset password", + "forgotPasswordHint": "Enter your account email. If it is registered, we will send a reset link. Open the link in a browser to choose a new password.", + "forgotPasswordSubmit": "Send reset link", + "forgotPasswordCheckEmail": "If an account exists for that email, a reset link has been sent. Open it in a browser, then sign in here.", + "forgotPasswordFailed": "Failed to request password reset", + "resetPasswordTitle": "Choose a new password", + "resetPasswordHint": "Enter a new password for your account.", + "resetPasswordSubmit": "Update password", + "resetPasswordSuccess": "Password updated. Please sign in.", + "resetPasswordFailed": "Failed to reset password", + "resetPasswordInvalidToken": "This reset link is missing or invalid.", "serverUrlLabel": "Server URL *", "enterServerUrl": "Enter server URL", "enterValidServerUrl": "Enter a valid server host or URL", diff --git a/ui/grom/lib/l10n/app_localizations.dart b/ui/grom/lib/l10n/app_localizations.dart index 51d569f..04ad85a 100644 --- a/ui/grom/lib/l10n/app_localizations.dart +++ b/ui/grom/lib/l10n/app_localizations.dart @@ -262,6 +262,78 @@ abstract class AppLocalizations { /// **'Passwords do not match'** String get passwordsDoNotMatch; + /// No description provided for @forgotPasswordLink. + /// + /// In en, this message translates to: + /// **'Forgot password?'** + String get forgotPasswordLink; + + /// No description provided for @forgotPasswordTitle. + /// + /// In en, this message translates to: + /// **'Reset password'** + String get forgotPasswordTitle; + + /// No description provided for @forgotPasswordHint. + /// + /// In en, this message translates to: + /// **'Enter your account email. If it is registered, we will send a reset link. Open the link in a browser to choose a new password.'** + String get forgotPasswordHint; + + /// No description provided for @forgotPasswordSubmit. + /// + /// In en, this message translates to: + /// **'Send reset link'** + String get forgotPasswordSubmit; + + /// No description provided for @forgotPasswordCheckEmail. + /// + /// In en, this message translates to: + /// **'If an account exists for that email, a reset link has been sent. Open it in a browser, then sign in here.'** + String get forgotPasswordCheckEmail; + + /// No description provided for @forgotPasswordFailed. + /// + /// In en, this message translates to: + /// **'Failed to request password reset'** + String get forgotPasswordFailed; + + /// No description provided for @resetPasswordTitle. + /// + /// In en, this message translates to: + /// **'Choose a new password'** + String get resetPasswordTitle; + + /// No description provided for @resetPasswordHint. + /// + /// In en, this message translates to: + /// **'Enter a new password for your account.'** + String get resetPasswordHint; + + /// No description provided for @resetPasswordSubmit. + /// + /// In en, this message translates to: + /// **'Update password'** + String get resetPasswordSubmit; + + /// No description provided for @resetPasswordSuccess. + /// + /// In en, this message translates to: + /// **'Password updated. Please sign in.'** + String get resetPasswordSuccess; + + /// No description provided for @resetPasswordFailed. + /// + /// In en, this message translates to: + /// **'Failed to reset password'** + String get resetPasswordFailed; + + /// No description provided for @resetPasswordInvalidToken. + /// + /// In en, this message translates to: + /// **'This reset link is missing or invalid.'** + String get resetPasswordInvalidToken; + /// No description provided for @serverUrlLabel. /// /// In en, this message translates to: diff --git a/ui/grom/lib/l10n/app_localizations_de.dart b/ui/grom/lib/l10n/app_localizations_de.dart index 4c0f7a2..582d223 100644 --- a/ui/grom/lib/l10n/app_localizations_de.dart +++ b/ui/grom/lib/l10n/app_localizations_de.dart @@ -95,6 +95,49 @@ class AppLocalizationsDe extends AppLocalizations { @override String get passwordsDoNotMatch => 'Passwörter stimmen nicht überein'; + @override + String get forgotPasswordLink => 'Passwort vergessen?'; + + @override + String get forgotPasswordTitle => 'Passwort zurücksetzen'; + + @override + String get forgotPasswordHint => + 'Geben Sie die E-Mail Ihres Kontos ein. Falls sie registriert ist, senden wir einen Reset-Link. Öffnen Sie den Link im Browser, um ein neues Passwort zu wählen.'; + + @override + String get forgotPasswordSubmit => 'Reset-Link senden'; + + @override + String get forgotPasswordCheckEmail => + 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link gesendet. Öffnen Sie ihn im Browser und melden Sie sich danach hier an.'; + + @override + String get forgotPasswordFailed => + 'Passwort-Reset konnte nicht angefordert werden'; + + @override + String get resetPasswordTitle => 'Neues Passwort wählen'; + + @override + String get resetPasswordHint => + 'Geben Sie ein neues Passwort für Ihr Konto ein.'; + + @override + String get resetPasswordSubmit => 'Passwort speichern'; + + @override + String get resetPasswordSuccess => + 'Passwort aktualisiert. Bitte melden Sie sich an.'; + + @override + String get resetPasswordFailed => + 'Passwort konnte nicht zurückgesetzt werden'; + + @override + String get resetPasswordInvalidToken => + 'Dieser Reset-Link fehlt oder ist ungültig.'; + @override String get serverUrlLabel => 'Server-URL *'; diff --git a/ui/grom/lib/l10n/app_localizations_en.dart b/ui/grom/lib/l10n/app_localizations_en.dart index 48b2b3b..03049df 100644 --- a/ui/grom/lib/l10n/app_localizations_en.dart +++ b/ui/grom/lib/l10n/app_localizations_en.dart @@ -94,6 +94,45 @@ class AppLocalizationsEn extends AppLocalizations { @override String get passwordsDoNotMatch => 'Passwords do not match'; + @override + String get forgotPasswordLink => 'Forgot password?'; + + @override + String get forgotPasswordTitle => 'Reset password'; + + @override + String get forgotPasswordHint => + 'Enter your account email. If it is registered, we will send a reset link. Open the link in a browser to choose a new password.'; + + @override + String get forgotPasswordSubmit => 'Send reset link'; + + @override + String get forgotPasswordCheckEmail => + 'If an account exists for that email, a reset link has been sent. Open it in a browser, then sign in here.'; + + @override + String get forgotPasswordFailed => 'Failed to request password reset'; + + @override + String get resetPasswordTitle => 'Choose a new password'; + + @override + String get resetPasswordHint => 'Enter a new password for your account.'; + + @override + String get resetPasswordSubmit => 'Update password'; + + @override + String get resetPasswordSuccess => 'Password updated. Please sign in.'; + + @override + String get resetPasswordFailed => 'Failed to reset password'; + + @override + String get resetPasswordInvalidToken => + 'This reset link is missing or invalid.'; + @override String get serverUrlLabel => 'Server URL *'; diff --git a/ui/grom/lib/l10n/app_localizations_ru.dart b/ui/grom/lib/l10n/app_localizations_ru.dart index d68697b..0f6ace5 100644 --- a/ui/grom/lib/l10n/app_localizations_ru.dart +++ b/ui/grom/lib/l10n/app_localizations_ru.dart @@ -94,6 +94,45 @@ class AppLocalizationsRu extends AppLocalizations { @override String get passwordsDoNotMatch => 'Пароли не совпадают'; + @override + String get forgotPasswordLink => 'Забыли пароль?'; + + @override + String get forgotPasswordTitle => 'Сброс пароля'; + + @override + String get forgotPasswordHint => + 'Введите email аккаунта. Если он зарегистрирован, мы отправим ссылку для сброса. Откройте её в браузере, чтобы задать новый пароль.'; + + @override + String get forgotPasswordSubmit => 'Отправить ссылку'; + + @override + String get forgotPasswordCheckEmail => + 'Если аккаунт с таким email есть, ссылка для сброса отправлена. Откройте её в браузере, затем войдите здесь.'; + + @override + String get forgotPasswordFailed => 'Не удалось запросить сброс пароля'; + + @override + String get resetPasswordTitle => 'Новый пароль'; + + @override + String get resetPasswordHint => 'Введите новый пароль для аккаунта.'; + + @override + String get resetPasswordSubmit => 'Сохранить пароль'; + + @override + String get resetPasswordSuccess => 'Пароль обновлён. Войдите в аккаунт.'; + + @override + String get resetPasswordFailed => 'Не удалось сбросить пароль'; + + @override + String get resetPasswordInvalidToken => + 'Ссылка для сброса отсутствует или недействительна.'; + @override String get serverUrlLabel => 'URL сервера *'; diff --git a/ui/grom/lib/l10n/app_ru.arb b/ui/grom/lib/l10n/app_ru.arb index be689ee..84e9c95 100644 --- a/ui/grom/lib/l10n/app_ru.arb +++ b/ui/grom/lib/l10n/app_ru.arb @@ -41,6 +41,18 @@ "confirmPasswordLabel": "Подтверждение пароля *", "confirmPassword": "Подтвердите пароль", "passwordsDoNotMatch": "Пароли не совпадают", + "forgotPasswordLink": "Забыли пароль?", + "forgotPasswordTitle": "Сброс пароля", + "forgotPasswordHint": "Введите email аккаунта. Если он зарегистрирован, мы отправим ссылку для сброса. Откройте её в браузере, чтобы задать новый пароль.", + "forgotPasswordSubmit": "Отправить ссылку", + "forgotPasswordCheckEmail": "Если аккаунт с таким email есть, ссылка для сброса отправлена. Откройте её в браузере, затем войдите здесь.", + "forgotPasswordFailed": "Не удалось запросить сброс пароля", + "resetPasswordTitle": "Новый пароль", + "resetPasswordHint": "Введите новый пароль для аккаунта.", + "resetPasswordSubmit": "Сохранить пароль", + "resetPasswordSuccess": "Пароль обновлён. Войдите в аккаунт.", + "resetPasswordFailed": "Не удалось сбросить пароль", + "resetPasswordInvalidToken": "Ссылка для сброса отсутствует или недействительна.", "serverUrlLabel": "URL сервера *", "enterServerUrl": "Введите URL сервера", "enterValidServerUrl": "Введите корректный хост или URL сервера", diff --git a/ui/grom/lib/login.dart b/ui/grom/lib/login.dart index da375ba..4589acf 100644 --- a/ui/grom/lib/login.dart +++ b/ui/grom/lib/login.dart @@ -3,6 +3,7 @@ import 'package:grom/l10n/app_localizations.dart'; import 'api_request.dart'; import 'auth_storage.dart'; +import 'forgot_password.dart'; import 'platform/is_mobile_client.dart'; import 'server_storage.dart'; import 'server_url_resolver.dart'; @@ -32,12 +33,15 @@ class _LoginFormState extends State { bool _isSubmitting = false; bool _obscurePassword = true; + bool _passwordResetEnabled = false; @override void initState() { super.initState(); if (isMobileClient) { _loadSavedServerUrl(); + } else { + _loadPasswordResetFlag(); } } @@ -46,6 +50,18 @@ class _LoginFormState extends State { if (url != null && mounted) { _serverUrlController.text = url; } + await _loadPasswordResetFlag(); + } + + Future _loadPasswordResetFlag() async { + try { + final info = await _api.getServerInfo(); + if (mounted) { + setState(() => _passwordResetEnabled = info.passwordResetEnabled); + } + } catch (_) { + // Keep forgot link hidden when server-info is unavailable. + } } @override @@ -70,6 +86,7 @@ class _LoginFormState extends State { _serverUrlController.text = resolved; } await ServerStorage.saveBaseUrl(resolved); + await _loadPasswordResetFlag(); } final result = await _api.login( @@ -160,7 +177,25 @@ class _LoginFormState extends State { return null; }, ), - const SizedBox(height: 24), + if (_passwordResetEnabled) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: _isSubmitting + ? null + : () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const ForgotPasswordPage(), + ), + ); + }, + child: Text(l10n.forgotPasswordLink), + ), + ) + else + const SizedBox(height: 24), FilledButton( onPressed: _isSubmitting ? null : _submit, child: _isSubmitting diff --git a/ui/grom/lib/main.dart b/ui/grom/lib/main.dart index 092745a..78bc09d 100644 --- a/ui/grom/lib/main.dart +++ b/ui/grom/lib/main.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; import 'package:grom/l10n/app_localizations.dart'; import 'app_theme.dart'; import 'locale_storage.dart'; import 'navigation/grom_shell.dart'; import 'platform/is_mobile_client.dart'; +import 'reset_password.dart'; import 'server_storage.dart'; import 'services/track_recording_bootstrap.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + usePathUrlStrategy(); await bootstrapTrackRecording(); if (isMobileClient) { @@ -50,6 +53,26 @@ class _GromAppState extends State { setState(() => _locale = locale); } + Route _onGenerateRoute(RouteSettings settings) { + final name = settings.name ?? '/'; + final uri = Uri.parse(name); + if (uri.path == '/reset-password') { + return MaterialPageRoute( + settings: settings, + builder: (_) => ResetPasswordPage( + token: uri.queryParameters['token'] ?? '', + ), + ); + } + return MaterialPageRoute( + settings: settings, + builder: (_) => GromShell( + locale: _locale, + onLocaleChanged: _setLocale, + ), + ); + } + @override Widget build(BuildContext context) { return MaterialApp( @@ -66,10 +89,8 @@ class _GromAppState extends State { return LocaleStorage.resolveLocale(deviceLocale); }, theme: buildAppTheme(), - home: GromShell( - locale: _locale, - onLocaleChanged: _setLocale, - ), + onGenerateRoute: _onGenerateRoute, + initialRoute: '/', ); } } diff --git a/ui/grom/lib/reset_password.dart b/ui/grom/lib/reset_password.dart new file mode 100644 index 0000000..9e6d2c5 --- /dev/null +++ b/ui/grom/lib/reset_password.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:grom/l10n/app_localizations.dart'; + +import 'api_request.dart'; + +class ResetPasswordPage extends StatefulWidget { + const ResetPasswordPage({super.key, required this.token}); + + final String token; + + @override + State createState() => _ResetPasswordPageState(); +} + +class _ResetPasswordPageState extends State { + final _formKey = GlobalKey(); + final _api = ApiRequest(); + final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); + bool _isSubmitting = false; + bool _obscurePassword = true; + bool _obscureConfirm = true; + + @override + void dispose() { + _passwordController.dispose(); + _confirmController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) { + return; + } + if (widget.token.trim().isEmpty) { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.resetPasswordInvalidToken)), + ); + return; + } + + setState(() => _isSubmitting = true); + final l10n = AppLocalizations.of(context)!; + + try { + await _api.resetPassword( + token: widget.token.trim(), + password: _passwordController.text, + ); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + content: Text(l10n.resetPasswordSuccess), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(l10n.signIn), + ), + ], + ), + ); + if (!mounted) return; + Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); + } on ApiException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.message)), + ); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.resetPasswordFailed)), + ); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar(title: Text(l10n.resetPasswordTitle)), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.resetPasswordHint), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + decoration: InputDecoration( + labelText: l10n.passwordLabel, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() => _obscurePassword = !_obscurePassword); + }, + ), + ), + obscureText: _obscurePassword, + textInputAction: TextInputAction.next, + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.enterPassword; + } + if (value.length < 8) { + return l10n.passwordMinLength; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmController, + decoration: InputDecoration( + labelText: l10n.confirmPasswordLabel, + border: const OutlineInputBorder(), + suffixIcon: IconButton( + icon: Icon( + _obscureConfirm + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() => _obscureConfirm = !_obscureConfirm); + }, + ), + ), + obscureText: _obscureConfirm, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.confirmPassword; + } + if (value != _passwordController.text) { + return l10n.passwordsDoNotMatch; + } + return null; + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isSubmitting ? null : _submit, + child: _isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(l10n.resetPasswordSubmit), + ), + ], + ), + ), + ), + ); + } +} diff --git a/ui/grom/pubspec.lock b/ui/grom/pubspec.lock index 7491731..245a16d 100644 --- a/ui/grom/pubspec.lock +++ b/ui/grom/pubspec.lock @@ -313,7 +313,7 @@ packages: source: sdk version: "0.0.0" flutter_web_plugins: - dependency: transitive + dependency: "direct main" description: flutter source: sdk version: "0.0.0" diff --git a/ui/grom/pubspec.yaml b/ui/grom/pubspec.yaml index 25cb125..6adcf4d 100644 --- a/ui/grom/pubspec.yaml +++ b/ui/grom/pubspec.yaml @@ -32,6 +32,8 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + flutter_web_plugins: + sdk: flutter intl: any # The following adds the Cupertino Icons font to your application. diff --git a/ui/grom/test/api_request_test.dart b/ui/grom/test/api_request_test.dart index 08e0092..4d93859 100644 --- a/ui/grom/test/api_request_test.dart +++ b/ui/grom/test/api_request_test.dart @@ -21,13 +21,16 @@ void main() { final info = ServerInfo.fromJson({ 'name': 'Home Lab', 'federation_enabled': true, + 'password_reset_enabled': true, }); expect(info.name, 'Home Lab'); expect(info.federationEnabled, isTrue); + expect(info.passwordResetEnabled, isTrue); final defaults = ServerInfo.fromJson({}); expect(defaults.name, 'Grom Home'); expect(defaults.federationEnabled, isFalse); + expect(defaults.passwordResetEnabled, isFalse); }); test('UserInfo.fromJson reads optional avatar fields', () { @@ -201,7 +204,11 @@ void main() { final okClient = MockClient((request) async { expect(request.url.path, '/api/v1/server-info'); return http.Response( - jsonEncode({'name': 'Lab', 'federation_enabled': true}), + jsonEncode({ + 'name': 'Lab', + 'federation_enabled': true, + 'password_reset_enabled': true, + }), 200, headers: {'content-type': 'application/json'}, ); @@ -209,6 +216,7 @@ void main() { final ok = await ApiRequest(client: okClient).getServerInfo(); expect(ok.name, 'Lab'); expect(ok.federationEnabled, isTrue); + expect(ok.passwordResetEnabled, isTrue); final failClient = MockClient((request) async { return http.Response('nope', 500); @@ -262,6 +270,90 @@ void main() { } }); + test('forgotPassword posts email and accepts 204', () async { + await ServerStorage.saveBaseUrl('https://grom.example'); + final client = MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/v1/auth/password/forgot'); + expect(request.headers['Content-Type'], 'application/json'); + final body = jsonDecode(request.body) as Map; + expect(body['email'], 'alice@example.com'); + return http.Response('', 204); + }); + await ApiRequest(client: client).forgotPassword(email: 'alice@example.com'); + }); + + test('forgotPassword maps 429 and 503 to ApiException', () async { + await ServerStorage.saveBaseUrl('https://grom.example'); + + final limited = MockClient((request) async { + return http.Response( + jsonEncode({'error': 'too many requests, try again later'}), + 429, + headers: {'content-type': 'application/json'}, + ); + }); + try { + await ApiRequest(client: limited).forgotPassword(email: 'alice@example.com'); + fail('expected ApiException'); + } on ApiException catch (e) { + expect(e.message, 'too many requests, try again later'); + expect(e.statusCode, 429); + } + + final disabled = MockClient((request) async { + return http.Response( + jsonEncode({'error': 'password reset is not configured'}), + 503, + headers: {'content-type': 'application/json'}, + ); + }); + try { + await ApiRequest(client: disabled).forgotPassword(email: 'alice@example.com'); + fail('expected ApiException'); + } on ApiException catch (e) { + expect(e.message, 'password reset is not configured'); + expect(e.statusCode, 503); + } + }); + + test('resetPassword posts token and password and accepts 204', () async { + await ServerStorage.saveBaseUrl('https://grom.example'); + final client = MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/v1/auth/password/reset'); + final body = jsonDecode(request.body) as Map; + expect(body['token'], 'reset-tok'); + expect(body['password'], 'newpassword1'); + return http.Response('', 204); + }); + await ApiRequest(client: client).resetPassword( + token: 'reset-tok', + password: 'newpassword1', + ); + }); + + test('resetPassword maps invalid token error', () async { + await ServerStorage.saveBaseUrl('https://grom.example'); + final client = MockClient((request) async { + return http.Response( + jsonEncode({'error': 'invalid or expired reset token'}), + 400, + headers: {'content-type': 'application/json'}, + ); + }); + try { + await ApiRequest(client: client).resetPassword( + token: 'bad', + password: 'newpassword1', + ); + fail('expected ApiException'); + } on ApiException catch (e) { + expect(e.message, 'invalid or expired reset token'); + expect(e.statusCode, 400); + } + }); + test('getStravaImportStatus returns decoded map', () async { await ServerStorage.saveBaseUrl('https://grom.example'); final client = MockClient((request) async {