diff --git a/.env.example b/.env.example index 4aae936..17f0fc5 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,19 @@ RMM_ALLOW_LEGACY_LUCI_PROXY=false # Set true when the UI is served through an HTTPS reverse proxy. RMM_COOKIE_SECURE=true RMM_PUBLIC_SCHEME=https +RMM_PUBLIC_URL=https://rmm.example.com RMM_METRIC_RETENTION_DAYS=30 +# Optional password recovery. Leave RMM_SMTP_HOST empty to disable it. +# Use starttls for port 587 or tls for implicit TLS on port 465. +RMM_SMTP_HOST= +RMM_SMTP_PORT=587 +RMM_SMTP_USERNAME= +RMM_SMTP_PASSWORD= +RMM_SMTP_FROM=OpenWrt RMM +RMM_SMTP_TLS_MODE=starttls +RMM_SMTP_SERVER_NAME= + RMM_HTTP_BIND_IP=127.0.0.1 RMM_HTTP_PORT=18080 diff --git a/compose.yaml b/compose.yaml index 0eafe69..41db2a8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -18,7 +18,15 @@ services: RMM_TUNNEL_HTTP_HOST: tunnel-ssh RMM_DEVICE_DOMAIN: "${RMM_DEVICE_DOMAIN:-}" RMM_PUBLIC_SCHEME: "${RMM_PUBLIC_SCHEME:-https}" + RMM_PUBLIC_URL: "${RMM_PUBLIC_URL:-}" RMM_METRIC_RETENTION_DAYS: "${RMM_METRIC_RETENTION_DAYS:-30}" + RMM_SMTP_HOST: "${RMM_SMTP_HOST:-}" + RMM_SMTP_PORT: "${RMM_SMTP_PORT:-587}" + RMM_SMTP_USERNAME: "${RMM_SMTP_USERNAME:-}" + RMM_SMTP_PASSWORD: "${RMM_SMTP_PASSWORD:-}" + RMM_SMTP_FROM: "${RMM_SMTP_FROM:-}" + RMM_SMTP_TLS_MODE: "${RMM_SMTP_TLS_MODE:-starttls}" + RMM_SMTP_SERVER_NAME: "${RMM_SMTP_SERVER_NAME:-}" ports: - "${RMM_HTTP_BIND_IP:-127.0.0.1}:${RMM_HTTP_PORT:-18080}:8080" volumes: diff --git a/docs/docker-compose.md b/docs/docker-compose.md index 0bbe4f2..2ffe9c9 100644 --- a/docs/docker-compose.md +++ b/docs/docker-compose.md @@ -25,6 +25,23 @@ Set a long `RMM_OPERATOR_PASSWORD`. Shared enrollment is disabled: users add rou 15-minute one-time grants created in the web UI. Leave `RMM_OPERATOR_TOKEN` empty unless an emergency/API bearer token is required. +To enable password recovery, set `RMM_PUBLIC_URL` to the external HTTPS address and +configure SMTP in `.env`: + +```dotenv +RMM_PUBLIC_URL=https://rmm.example.com +RMM_SMTP_HOST=smtp.example.com +RMM_SMTP_PORT=587 +RMM_SMTP_USERNAME=rmm@example.com +RMM_SMTP_PASSWORD=replace-with-the-smtp-password +RMM_SMTP_FROM=OpenWrt RMM +RMM_SMTP_TLS_MODE=starttls +``` + +Use `RMM_SMTP_TLS_MODE=tls` for implicit TLS on port 465. Plain SMTP is rejected in +production mode. Reset links are one-time, expire after 30 minutes, and revoke all +existing web sessions after the password is changed. + ## 2. Start The Stack ```powershell @@ -100,6 +117,11 @@ The actual port is selected per session. ## Operations +Administrators can create users, change their roles, disable accounts, and issue a +temporary password from the profile dialog. Every user receives routers through their +own one-time enrollment grants. A router can be transferred from its Expert tab after +the current user confirms their password; active LuCI access is closed during transfer. + View logs: ```powershell diff --git a/server/README.md b/server/README.md index 09b65d9..8ba823a 100644 --- a/server/README.md +++ b/server/README.md @@ -15,6 +15,11 @@ Environment variables: - `RMM_OPERATOR_PASSWORD` - required password used only when the bootstrap administrator is first created; rotate it later in the account UI - `RMM_OPERATOR_TOKEN` - optional emergency/API bearer token - `RMM_OPERATOR_USERNAME` - web UI username, default `admin` +- `RMM_PUBLIC_URL` - trusted external base URL used in password recovery links +- `RMM_SMTP_HOST`, `RMM_SMTP_PORT`, `RMM_SMTP_USERNAME`, `RMM_SMTP_PASSWORD`, + `RMM_SMTP_FROM` - optional SMTP delivery for password recovery +- `RMM_SMTP_TLS_MODE` - `starttls` (default), `tls`, or `none` in insecure local mode only +- `RMM_SMTP_SERVER_NAME` - optional TLS server name override - `RMM_COOKIE_SECURE` - defaults to `true` outside explicit development mode - `RMM_DEVICE_DOMAIN` - wildcard device domain, for example `routers.example.com` - `RMM_ALLOW_LEGACY_ENROLLMENT` - opt-in shared enrollment compatibility mode @@ -43,16 +48,20 @@ Agent API: Operator API: - `POST /api/auth/login` +- `POST /api/auth/password-reset/request` +- `POST /api/auth/password-reset/confirm` - `POST /api/auth/logout` - `GET /api/auth/me` - `PATCH /api/auth/profile` - `POST /api/auth/change-password` - `POST /api/auth/logout-all` - `GET|POST /api/users` (administrator only) +- `PATCH /api/users/{id}` (administrator only) - `POST /api/enrollment-grants` - `GET /api/devices` - `GET /api/devices/{id}` +- `POST /api/devices/{id}/transfer` - `POST /api/devices/{id}/commands` - `GET /api/devices/{id}/commands` - `GET /api/devices/{id}/commands/{command_id}` diff --git a/server/cmd/rmm-server/main.go b/server/cmd/rmm-server/main.go index 9ec2c0f..db25814 100644 --- a/server/cmd/rmm-server/main.go +++ b/server/cmd/rmm-server/main.go @@ -43,6 +43,34 @@ func main() { log.Fatal("RMM_ENROLLMENT_TOKEN still contains an insecure example value") } } + var passwordResetSender httpapi.PasswordResetSender + smtpHost := strings.TrimSpace(os.Getenv("RMM_SMTP_HOST")) + publicURL := strings.TrimRight(strings.TrimSpace(os.Getenv("RMM_PUBLIC_URL")), "/") + if smtpHost != "" { + tlsMode := env("RMM_SMTP_TLS_MODE", "starttls") + if !insecureDevMode && strings.EqualFold(tlsMode, "none") { + log.Fatal("RMM_SMTP_TLS_MODE=none is allowed only in insecure development mode") + } + if publicURL == "" { + log.Fatal("RMM_PUBLIC_URL is required when SMTP password recovery is enabled") + } + if !insecureDevMode && !strings.HasPrefix(strings.ToLower(publicURL), "https://") { + log.Fatal("RMM_PUBLIC_URL must use https when SMTP password recovery is enabled") + } + sender, smtpErr := httpapi.NewSMTPPasswordResetSender(httpapi.SMTPConfig{ + Host: smtpHost, + Port: envInt("RMM_SMTP_PORT", 587, 1, 65535), + Username: strings.TrimSpace(os.Getenv("RMM_SMTP_USERNAME")), + Password: os.Getenv("RMM_SMTP_PASSWORD"), + From: strings.TrimSpace(os.Getenv("RMM_SMTP_FROM")), + TLSMode: tlsMode, + ServerName: strings.TrimSpace(os.Getenv("RMM_SMTP_SERVER_NAME")), + }) + if smtpErr != nil { + log.Fatalf("invalid SMTP configuration: %v", smtpErr) + } + passwordResetSender = sender + } st, err := store.OpenSQLite(context.Background(), dbPath) if err != nil { @@ -65,6 +93,8 @@ func main() { TunnelHTTPHost: env("RMM_TUNNEL_HTTP_HOST", "tunnel-ssh"), DeviceDomain: strings.TrimSpace(os.Getenv("RMM_DEVICE_DOMAIN")), PublicScheme: env("RMM_PUBLIC_SCHEME", "https"), + PublicURL: publicURL, + PasswordResetSender: passwordResetSender, StaticDir: env("RMM_WEB_DIR", "web"), }) diff --git a/server/internal/httpapi/accounts.go b/server/internal/httpapi/accounts.go index 866be18..321cfe0 100644 --- a/server/internal/httpapi/accounts.go +++ b/server/internal/httpapi/accounts.go @@ -2,6 +2,7 @@ package httpapi import ( "net/http" + "net/mail" "regexp" "strings" "time" @@ -31,6 +32,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { return } req.Username = strings.TrimSpace(req.Username) + req.DisplayName = strings.TrimSpace(req.DisplayName) + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) req.Role = strings.ToLower(strings.TrimSpace(req.Role)) if req.Role == "" { req.Role = "user" @@ -43,15 +46,34 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "role must be user or admin") return } + if len([]rune(req.DisplayName)) > 80 { + writeError(w, http.StatusBadRequest, "display_name must not exceed 80 characters") + return + } + if len(req.Email) > 254 { + writeError(w, http.StatusBadRequest, "email must not exceed 254 characters") + return + } + if req.Email != "" { + address, err := mail.ParseAddress(req.Email) + if err != nil || !strings.EqualFold(address.Address, req.Email) { + writeError(w, http.StatusBadRequest, "email is invalid") + return + } + } passwordHash, err := authn.HashPassword(req.Password) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } - user, err := a.store.CreateUser(r.Context(), req.Username, passwordHash, req.Role) + user, err := a.store.CreateUser(r.Context(), req.Username, req.DisplayName, req.Email, passwordHash, req.Role) if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "email is already in use") { + writeError(w, http.StatusConflict, "email is already in use") + return + } if strings.Contains(strings.ToLower(err.Error()), "unique") { - writeError(w, http.StatusConflict, "username is already in use") + writeError(w, http.StatusConflict, "username or email is already in use") return } writeError(w, http.StatusInternalServerError, "failed to create user") @@ -77,8 +99,9 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { if !decodeJSON(w, r, &req) { return } - if req.Disabled == nil && req.Password == "" { - writeError(w, http.StatusBadRequest, "disabled or password is required") + req.Role = strings.ToLower(strings.TrimSpace(req.Role)) + if req.Disabled == nil && req.Password == "" && req.Role == "" { + writeError(w, http.StatusBadRequest, "disabled, password or role is required") return } principal, _ := principalFromContext(r.Context()) @@ -86,6 +109,14 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "you cannot disable your current account") return } + if parts[2] == principal.User.ID && req.Role != "" && req.Role != principal.User.Role { + writeError(w, http.StatusBadRequest, "you cannot change the role of your current account") + return + } + if req.Role != "" && req.Role != "user" && req.Role != "admin" { + writeError(w, http.StatusBadRequest, "role must be user or admin") + return + } passwordHash := "" var err error if req.Password != "" { @@ -95,7 +126,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { return } } - user, found, err := a.store.UpdateUserSecurity(r.Context(), parts[2], req.Disabled, passwordHash) + user, found, err := a.store.UpdateUserSecurity(r.Context(), parts[2], req.Disabled, passwordHash, req.Role) if err != nil { if strings.Contains(strings.ToLower(err.Error()), "last active") { writeError(w, http.StatusConflict, err.Error()) @@ -112,11 +143,72 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { "updated_user_id": user.ID, "disabled": req.Disabled, "password_reset": passwordHash != "", + "role": req.Role, "request_id": requestID(r.Context()), })) writeJSON(w, http.StatusOK, user) } +func (a *App) handleTransferDevice(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) != 4 || parts[0] != "api" || parts[1] != "devices" || parts[3] != "transfer" { + writeError(w, http.StatusNotFound, "not found") + return + } + var req deviceTransferRequest + if !decodeJSON(w, r, &req) { + return + } + req.TargetUsername = strings.TrimSpace(req.TargetUsername) + if !usernamePattern.MatchString(req.TargetUsername) || req.CurrentPassword == "" { + writeError(w, http.StatusBadRequest, "target username and current password are required") + return + } + principal, _ := principalFromContext(r.Context()) + _, currentHash, found, err := a.store.GetUserByUsername(r.Context(), principal.User.Username) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to verify transfer") + return + } + if !found || !authn.VerifyPassword(currentHash, req.CurrentPassword) { + writeError(w, http.StatusUnauthorized, "current password is incorrect") + return + } + target, _, found, err := a.store.GetUserByUsername(r.Context(), req.TargetUsername) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to find target account") + return + } + if !found || target.Disabled { + writeError(w, http.StatusConflict, "target account is unavailable") + return + } + currentDevice, found, err := a.store.GetDevice(r.Context(), parts[2]) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load device owner") + return + } + if !found || target.ID == currentDevice.OwnerUserID { + writeError(w, http.StatusConflict, "device is already assigned to that account") + return + } + device, transferred, err := a.store.TransferDevice(r.Context(), parts[2], target.ID, principal.User.ID, principal.IsAdmin()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to transfer device") + return + } + if !transferred { + writeError(w, http.StatusConflict, "device is already assigned to that account") + return + } + a.decorateDevice(&device) + _, _ = a.store.AddAuditEvent(r.Context(), principal.User.Username, "device.transfer", device.ID, "", mustJSON(map[string]string{ + "target_user_id": target.ID, + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, device) +} + func (a *App) handleCreateEnrollmentGrant(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") var req enrollmentGrantRequest diff --git a/server/internal/httpapi/auth.go b/server/internal/httpapi/auth.go index 7e12d71..f3d5ea1 100644 --- a/server/internal/httpapi/auth.go +++ b/server/internal/httpapi/auth.go @@ -5,9 +5,10 @@ import ( "crypto/rand" "crypto/subtle" "encoding/base64" + "log" "net" - "net/mail" "net/http" + "net/mail" "net/url" "strings" "sync" @@ -155,6 +156,10 @@ func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { principal, _ := principalFromContext(r.Context()) user, found, err := a.store.UpdateUserProfile(r.Context(), principal.User.ID, req.DisplayName, req.Email) if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "email is already in use") { + writeError(w, http.StatusConflict, "email is already in use") + return + } writeError(w, http.StatusInternalServerError, "failed to update profile") return } @@ -168,6 +173,94 @@ func (a *App) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"user": user}) } +func (a *App) handlePasswordResetRequest(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !sameOrigin(r) { + writeError(w, http.StatusForbidden, "cross-origin request rejected") + return + } + if a.passwordResetSender == nil || a.publicURL == "" { + writeError(w, http.StatusServiceUnavailable, "password recovery is not configured") + return + } + var req passwordResetRequest + if !decodeJSON(w, r, &req) { + return + } + identifier := strings.TrimSpace(req.Identifier) + if identifier == "" || len(identifier) > 254 { + writeError(w, http.StatusBadRequest, "username or email is required") + return + } + limitKey := strings.ToLower(identifier) + if !a.passwordResetLimiter.Allow(limitKey) { + w.Header().Set("Retry-After", "3600") + writeError(w, http.StatusTooManyRequests, "too many password recovery attempts") + return + } + a.passwordResetLimiter.Fail(limitKey) + user, found, err := a.store.GetUserForPasswordReset(r.Context(), identifier) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to start password recovery") + return + } + if found { + token, err := randomToken(32) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to start password recovery") + return + } + if err := a.store.CreatePasswordReset(r.Context(), user.ID, store.TokenHash(token), time.Now().UTC().Add(30*time.Minute)); err != nil { + writeError(w, http.StatusInternalServerError, "failed to start password recovery") + return + } + resetURL := a.publicURL + "/#password-reset=" + url.QueryEscape(token) + go func(userID, recipient, targetURL string) { + if err := a.passwordResetSender.SendPasswordReset(context.Background(), recipient, targetURL); err != nil { + log.Printf("password reset email delivery failed for user %s: %v", userID, err) + } + }(user.ID, user.Email, resetURL) + } + writeJSON(w, http.StatusAccepted, map[string]string{ + "status": "accepted", + "message": "if the account has a recovery email, a reset link will be sent", + }) +} + +func (a *App) handlePasswordResetConfirm(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !sameOrigin(r) { + writeError(w, http.StatusForbidden, "cross-origin request rejected") + return + } + var req passwordResetConfirmRequest + if !decodeJSON(w, r, &req) { + return + } + if len(req.Token) < 32 || len(req.Token) > 512 { + writeError(w, http.StatusBadRequest, "password reset link is invalid or expired") + return + } + passwordHash, err := authn.HashPassword(req.NewPassword) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + reset, err := a.store.ResetPassword(r.Context(), store.TokenHash(req.Token), passwordHash) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to reset password") + return + } + if !reset { + writeError(w, http.StatusBadRequest, "password reset link is invalid or expired") + return + } + _, _ = a.store.AddAuditEvent(r.Context(), "password-reset", "auth.password_reset", "", "", mustJSON(map[string]string{ + "request_id": requestID(r.Context()), + })) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) { var req changePasswordRequest if !decodeJSON(w, r, &req) { diff --git a/server/internal/httpapi/mail.go b/server/internal/httpapi/mail.go new file mode 100644 index 0000000..13ed3c3 --- /dev/null +++ b/server/internal/httpapi/mail.go @@ -0,0 +1,169 @@ +package httpapi + +import ( + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "net" + "net/mail" + "net/textproto" + "strconv" + "strings" + "time" +) + +type PasswordResetSender interface { + SendPasswordReset(ctx context.Context, recipient, resetURL string) error +} + +type SMTPConfig struct { + Host string + Port int + Username string + Password string + From string + TLSMode string + ServerName string +} + +type smtpPasswordResetSender struct { + config SMTPConfig + from *mail.Address +} + +func NewSMTPPasswordResetSender(config SMTPConfig) (PasswordResetSender, error) { + config.Host = strings.TrimSpace(config.Host) + config.Username = strings.TrimSpace(config.Username) + config.From = strings.TrimSpace(config.From) + config.TLSMode = strings.ToLower(strings.TrimSpace(config.TLSMode)) + config.ServerName = strings.TrimSpace(config.ServerName) + if config.Host == "" || config.From == "" { + return nil, errors.New("SMTP host and sender are required") + } + if config.Port <= 0 || config.Port > 65535 { + return nil, errors.New("SMTP port must be between 1 and 65535") + } + if config.TLSMode == "" { + config.TLSMode = "starttls" + } + if config.TLSMode != "starttls" && config.TLSMode != "tls" && config.TLSMode != "none" { + return nil, errors.New("SMTP TLS mode must be starttls, tls or none") + } + if config.TLSMode == "none" && (config.Username != "" || config.Password != "") { + return nil, errors.New("SMTP authentication requires TLS") + } + from, err := mail.ParseAddress(config.From) + if err != nil || strings.ContainsAny(config.From, "\r\n") { + return nil, errors.New("SMTP sender is invalid") + } + if config.ServerName == "" { + config.ServerName = config.Host + } + return &smtpPasswordResetSender{config: config, from: from}, nil +} + +func (s *smtpPasswordResetSender) SendPasswordReset(ctx context.Context, recipient, resetURL string) error { + to, err := mail.ParseAddress(strings.TrimSpace(recipient)) + if err != nil || strings.ContainsAny(recipient, "\r\n") { + return errors.New("password reset recipient is invalid") + } + if !strings.HasPrefix(resetURL, "https://") && !strings.HasPrefix(resetURL, "http://") { + return errors.New("password reset URL is invalid") + } + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + address := net.JoinHostPort(s.config.Host, strconv.Itoa(s.config.Port)) + dialer := &net.Dialer{Timeout: 10 * time.Second} + var conn net.Conn + if s.config.TLSMode == "tls" { + conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: s.config.ServerName}) + if err != nil { + return fmt.Errorf("connect to SMTP over TLS: %w", err) + } + } else { + conn, err = dialer.DialContext(ctx, "tcp", address) + if err != nil { + return fmt.Errorf("connect to SMTP: %w", err) + } + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + text := textproto.NewConn(conn) + if _, _, err := text.ReadResponse(220); err != nil { + return fmt.Errorf("read SMTP greeting: %w", err) + } + ehlo, err := smtpCommand(text, 250, "EHLO rmm.local") + if err != nil { + return fmt.Errorf("SMTP EHLO: %w", err) + } + if s.config.TLSMode == "starttls" { + if !strings.Contains(strings.ToUpper(ehlo), "STARTTLS") { + return errors.New("SMTP server does not support STARTTLS") + } + if _, err := smtpCommand(text, 220, "STARTTLS"); err != nil { + return fmt.Errorf("start SMTP TLS: %w", err) + } + tlsConn := tls.Client(conn, &tls.Config{MinVersion: tls.VersionTLS12, ServerName: s.config.ServerName}) + if err := tlsConn.HandshakeContext(ctx); err != nil { + return fmt.Errorf("negotiate SMTP TLS: %w", err) + } + conn = tlsConn + text = textproto.NewConn(conn) + if _, err := smtpCommand(text, 250, "EHLO rmm.local"); err != nil { + return fmt.Errorf("SMTP EHLO after TLS: %w", err) + } + } + if s.config.Username != "" { + credentials := base64.StdEncoding.EncodeToString([]byte("\x00" + s.config.Username + "\x00" + s.config.Password)) + if _, err := smtpCommand(text, 235, "AUTH PLAIN %s", credentials); err != nil { + return fmt.Errorf("authenticate to SMTP: %w", err) + } + } + if _, err := smtpCommand(text, 250, "MAIL FROM:<%s>", s.from.Address); err != nil { + return fmt.Errorf("set SMTP sender: %w", err) + } + if _, err := smtpCommand(text, 250, "RCPT TO:<%s>", to.Address); err != nil { + return fmt.Errorf("set SMTP recipient: %w", err) + } + if _, err := smtpCommand(text, 354, "DATA"); err != nil { + return fmt.Errorf("open SMTP message: %w", err) + } + w := text.DotWriter() + message := "From: " + s.from.String() + "\r\n" + + "To: " + to.String() + "\r\n" + + "Subject: OpenWrt RMM password reset\r\n" + + "MIME-Version: 1.0\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n\r\n" + + "A password reset was requested for your OpenWrt RMM account.\r\n\r\n" + + resetURL + "\r\n\r\n" + + "The link expires in 30 minutes. If you did not request this, ignore this message.\r\n" + if _, err := w.Write([]byte(message)); err != nil { + w.Close() + return fmt.Errorf("write SMTP message: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("send SMTP message: %w", err) + } + if _, _, err := text.ReadResponse(250); err != nil { + return fmt.Errorf("confirm SMTP message: %w", err) + } + _, _ = smtpCommand(text, 221, "QUIT") + return nil +} + +func smtpCommand(conn *textproto.Conn, expectedCode int, format string, args ...any) (string, error) { + id, err := conn.Cmd(format, args...) + if err != nil { + return "", err + } + conn.StartResponse(id) + defer conn.EndResponse(id) + _, message, err := conn.ReadResponse(expectedCode) + return message, err +} diff --git a/server/internal/httpapi/mail_test.go b/server/internal/httpapi/mail_test.go new file mode 100644 index 0000000..f97fc80 --- /dev/null +++ b/server/internal/httpapi/mail_test.go @@ -0,0 +1,107 @@ +package httpapi + +import ( + "bufio" + "context" + "fmt" + "net" + "strings" + "testing" + "time" +) + +func TestSMTPPasswordResetSender(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + messages := make(chan string, 1) + serverErrors := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + serverErrors <- err + return + } + defer conn.Close() + reader := bufio.NewReader(conn) + if _, err := fmt.Fprint(conn, "220 smtp.test ESMTP\r\n"); err != nil { + serverErrors <- err + return + } + var message strings.Builder + inData := false + for { + line, err := reader.ReadString('\n') + if err != nil { + serverErrors <- err + return + } + trimmed := strings.TrimRight(line, "\r\n") + if inData { + if trimmed == "." { + messages <- message.String() + inData = false + _, err = fmt.Fprint(conn, "250 queued\r\n") + } else { + message.WriteString(trimmed + "\n") + } + } else { + switch { + case strings.HasPrefix(trimmed, "EHLO "): + _, err = fmt.Fprint(conn, "250-smtp.test\r\n250 OK\r\n") + case strings.HasPrefix(trimmed, "MAIL FROM:"), strings.HasPrefix(trimmed, "RCPT TO:"): + _, err = fmt.Fprint(conn, "250 OK\r\n") + case trimmed == "DATA": + inData = true + _, err = fmt.Fprint(conn, "354 End data\r\n") + case trimmed == "QUIT": + _, err = fmt.Fprint(conn, "221 Bye\r\n") + if err == nil { + serverErrors <- nil + } + return + default: + err = fmt.Errorf("unexpected SMTP command %q", trimmed) + } + } + if err != nil { + serverErrors <- err + return + } + } + }() + + address := listener.Addr().(*net.TCPAddr) + sender, err := NewSMTPPasswordResetSender(SMTPConfig{ + Host: "127.0.0.1", Port: address.Port, From: "RMM ", TLSMode: "none", + }) + if err != nil { + t.Fatal(err) + } + resetURL := "https://rmm.example.test/#password-reset=test-token" + if err := sender.SendPasswordReset(context.Background(), "owner@example.test", resetURL); err != nil { + t.Fatal(err) + } + select { + case message := <-messages: + if !strings.Contains(message, resetURL) || !strings.Contains(message, "To: ") { + t.Fatalf("unexpected SMTP message: %s", message) + } + case <-time.After(2 * time.Second): + t.Fatal("SMTP message was not received") + } + if err := <-serverErrors; err != nil { + t.Fatal(err) + } +} + +func TestSMTPPasswordResetSenderRejectsAuthenticationWithoutTLS(t *testing.T) { + _, err := NewSMTPPasswordResetSender(SMTPConfig{ + Host: "smtp.example.test", Port: 25, From: "rmm@example.test", TLSMode: "none", Username: "rmm", + }) + if err == nil { + t.Fatal("expected SMTP authentication without TLS to be rejected") + } +} diff --git a/server/internal/httpapi/security_test.go b/server/internal/httpapi/security_test.go index 7ffb81d..72335ad 100644 --- a/server/internal/httpapi/security_test.go +++ b/server/internal/httpapi/security_test.go @@ -64,6 +64,18 @@ func TestMultiUserEnrollmentAndDeviceIsolation(t *testing.T) { authRequestJSON(t, bob, http.MethodPost, srv.URL+"/api/devices/bulk-commands", map[string]any{ "device_ids": []string{aliceDevice.DeviceID}, "type": "ping", "args": map[string]any{"target": "1.1.1.1"}, }, http.StatusNotFound, nil) + authRequestJSON(t, alice, http.MethodPost, srv.URL+"/api/devices/"+aliceDevice.DeviceID+"/transfer", map[string]any{ + "target_username": "bob", "current_password": "alice-password-long", + }, http.StatusOK, nil) + authRequestJSON(t, alice, http.MethodGet, srv.URL+"/api/devices/"+aliceDevice.DeviceID, nil, http.StatusNotFound, nil) + authRequestJSON(t, bob, http.MethodGet, srv.URL+"/api/devices/"+aliceDevice.DeviceID, nil, http.StatusOK, nil) + authRequestJSON(t, admin, http.MethodPatch, srv.URL+"/api/users/"+aliceUser.ID, map[string]any{ + "role": "admin", + }, http.StatusOK, nil) + authRequestJSON(t, admin, http.MethodPatch, srv.URL+"/api/users/"+aliceUser.ID, map[string]any{ + "role": "user", + }, http.StatusOK, nil) + alice = authenticatedClient(t, srv.URL, "alice", "alice-password-long") req, err := http.NewRequest(http.MethodPost, srv.URL+"/api/enrollment-grants", jsonBody(map[string]any{})) if err != nil { @@ -86,6 +98,77 @@ func TestMultiUserEnrollmentAndDeviceIsolation(t *testing.T) { authRequestJSON(t, bob, http.MethodGet, srv.URL+"/api/devices", nil, http.StatusUnauthorized, nil) } +type capturedResetMessage struct { + recipient string + resetURL string +} + +type captureResetSender struct { + messages chan capturedResetMessage +} + +func (s *captureResetSender) SendPasswordReset(_ context.Context, recipient, resetURL string) error { + s.messages <- capturedResetMessage{recipient: recipient, resetURL: resetURL} + return nil +} + +func TestPasswordResetIsOneTimeAndRevokesSessions(t *testing.T) { + st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "password-reset.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + sender := &captureResetSender{messages: make(chan capturedResetMessage, 1)} + srv := httptest.NewServer(httpapi.NewHandler(st, httpapi.Config{ + OperatorUsername: "admin", + OperatorPassword: "initial-password-123", + PublicURL: "https://rmm.example.test", + PasswordResetSender: sender, + })) + defer srv.Close() + client := authenticatedClient(t, srv.URL, "admin", "initial-password-123") + authRequestJSON(t, client, http.MethodPatch, srv.URL+"/api/auth/profile", map[string]any{ + "display_name": "Owner", "email": "owner@example.test", + }, http.StatusOK, nil) + authRequestJSON(t, client, http.MethodPost, srv.URL+"/api/users", map[string]any{ + "username": "duplicate-email", "email": "owner@example.test", "password": "temporary-password-123", "role": "user", + }, http.StatusConflict, nil) + + authRequestJSON(t, &http.Client{}, http.MethodPost, srv.URL+"/api/auth/password-reset/request", map[string]any{ + "identifier": "owner@example.test", + }, http.StatusAccepted, nil) + var message capturedResetMessage + select { + case message = <-sender.messages: + case <-time.After(2 * time.Second): + t.Fatal("password reset email was not sent") + } + parsed, err := url.Parse(message.resetURL) + if err != nil { + t.Fatal(err) + } + if message.recipient != "owner@example.test" || !strings.HasPrefix(parsed.Fragment, "password-reset=") { + t.Fatalf("unexpected password reset message: %#v", message) + } + token, err := url.QueryUnescape(strings.TrimPrefix(parsed.Fragment, "password-reset=")) + if err != nil { + t.Fatal(err) + } + authRequestJSON(t, &http.Client{}, http.MethodPost, srv.URL+"/api/auth/password-reset/confirm", map[string]any{ + "token": token, "new_password": "recovered-password-123", + }, http.StatusOK, nil) + authRequestJSON(t, client, http.MethodGet, srv.URL+"/api/auth/me", nil, http.StatusUnauthorized, nil) + authRequestJSON(t, &http.Client{}, http.MethodPost, srv.URL+"/api/auth/password-reset/confirm", map[string]any{ + "token": token, "new_password": "another-password-123", + }, http.StatusBadRequest, nil) + authRequestJSON(t, &http.Client{}, http.MethodPost, srv.URL+"/api/auth/login", map[string]any{ + "username": "admin", "password": "initial-password-123", + }, http.StatusUnauthorized, nil) + authRequestJSON(t, &http.Client{}, http.MethodPost, srv.URL+"/api/auth/login", map[string]any{ + "username": "admin", "password": "recovered-password-123", + }, http.StatusOK, nil) +} + func TestDeviceDomainUsesOneTimeLuCIAccessGrant(t *testing.T) { st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "luci-security.db")) if err != nil { diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index bbc3231..534c69b 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -29,8 +29,8 @@ type Store interface { EnrollDeviceWithGrant(ctx context.Context, tokenHash, hostname, openwrtVersion string) (store.EnrolledDevice, bool, error) AuthorizeDevice(ctx context.Context, deviceID, token string) (bool, error) EnsureBootstrapUser(ctx context.Context, username, passwordHash string) (model.User, error) - CreateUser(ctx context.Context, username, passwordHash, role string) (model.User, error) - UpdateUserSecurity(ctx context.Context, userID string, disabled *bool, passwordHash string) (model.User, bool, error) + CreateUser(ctx context.Context, username, displayName, email, passwordHash, role string) (model.User, error) + UpdateUserSecurity(ctx context.Context, userID string, disabled *bool, passwordHash, role string) (model.User, bool, error) ListUsers(ctx context.Context) ([]model.User, error) GetUserByUsername(ctx context.Context, username string) (model.User, string, bool, error) GetUserByID(ctx context.Context, id string) (model.User, bool, error) @@ -40,11 +40,15 @@ type Store interface { UpdateUserProfile(ctx context.Context, userID, displayName, email string) (model.User, bool, error) UpdateOwnPassword(ctx context.Context, userID, passwordHash, currentSessionHash string) error RevokeUserSessions(ctx context.Context, userID string) error + GetUserForPasswordReset(ctx context.Context, identifier string) (model.User, bool, error) + CreatePasswordReset(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error + ResetPassword(ctx context.Context, tokenHash, passwordHash string) (bool, error) CreateEnrollmentGrant(ctx context.Context, userID, dnsLabel, tokenHash string, expiresAt time.Time) (model.EnrollmentGrant, error) ListDevicesForUser(ctx context.Context, userID string, admin bool) ([]model.Device, error) DeviceAccessible(ctx context.Context, deviceID, userID string, admin bool) (bool, error) GetDeviceByDNSLabel(ctx context.Context, dnsLabel string) (model.Device, bool, error) UpdateDeviceDNSLabel(ctx context.Context, deviceID, dnsLabel string) (model.Device, bool, error) + TransferDevice(ctx context.Context, deviceID, targetUserID, requesterUserID string, admin bool) (model.Device, bool, error) CreateDeviceAccessGrant(ctx context.Context, tokenHash, userID, deviceID, remoteSessionID string, expiresAt time.Time) error ConsumeDeviceAccessGrant(ctx context.Context, grantHash, sessionHash, dnsLabel string, sessionExpiresAt time.Time) (store.AccessRoute, bool, error) AuthorizeDeviceAccessSession(ctx context.Context, sessionHash, dnsLabel string) (store.AccessRoute, bool, error) @@ -89,6 +93,8 @@ type Config struct { TunnelHTTPHost string DeviceDomain string PublicScheme string + PublicURL string + PasswordResetSender PasswordResetSender StaticDir string } @@ -103,7 +109,10 @@ type App struct { tunnelHTTPHost string deviceDomain string publicScheme string + publicURL string + passwordResetSender PasswordResetSender loginLimiter *loginRateLimiter + passwordResetLimiter *loginRateLimiter loginSlots chan struct{} } @@ -178,14 +187,17 @@ type loginRequest struct { } type createUserRequest struct { - Username string `json:"username"` - Password string `json:"password"` - Role string `json:"role"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` } type updateUserRequest struct { Disabled *bool `json:"disabled"` Password string `json:"password"` + Role string `json:"role"` } type updateProfileRequest struct { @@ -198,6 +210,20 @@ type changePasswordRequest struct { NewPassword string `json:"new_password"` } +type passwordResetRequest struct { + Identifier string `json:"identifier"` +} + +type passwordResetConfirmRequest struct { + Token string `json:"token"` + NewPassword string `json:"new_password"` +} + +type deviceTransferRequest struct { + TargetUsername string `json:"target_username"` + CurrentPassword string `json:"current_password"` +} + type enrollmentGrantRequest struct { DNSLabel string `json:"dns_label"` ExpiresSeconds int `json:"expires_seconds"` @@ -214,6 +240,12 @@ func NewHandler(s Store, cfg Config) http.Handler { if cfg.OperatorPassword == "" { cfg.OperatorPassword = cfg.OperatorToken } + if cfg.PasswordResetSender != nil { + publicURL, err := url.Parse(strings.TrimSpace(cfg.PublicURL)) + if err != nil || publicURL.Host == "" || (publicURL.Scheme != "https" && publicURL.Scheme != "http") || publicURL.RawQuery != "" || publicURL.Fragment != "" { + panic("password recovery requires an absolute RMM public URL without query or fragment") + } + } passwordHash, err := authn.HashPassword(cfg.OperatorPassword) if err != nil { panic("invalid bootstrap operator password: " + err.Error()) @@ -236,7 +268,10 @@ func NewHandler(s Store, cfg Config) http.Handler { tunnelHTTPHost: strings.TrimSpace(cfg.TunnelHTTPHost), deviceDomain: strings.Trim(strings.ToLower(strings.TrimSpace(cfg.DeviceDomain)), "."), publicScheme: strings.ToLower(strings.TrimSpace(cfg.PublicScheme)), + publicURL: strings.TrimRight(strings.TrimSpace(cfg.PublicURL), "/"), + passwordResetSender: cfg.PasswordResetSender, loginLimiter: newLoginRateLimiter(5, 5*time.Minute), + passwordResetLimiter: newLoginRateLimiter(3, time.Hour), loginSlots: make(chan struct{}, 4), } if a.tunnelHTTPHost == "" { @@ -252,6 +287,8 @@ func NewHandler(s Store, cfg Config) http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST /api/auth/login", a.handleLogin) + mux.HandleFunc("POST /api/auth/password-reset/request", a.handlePasswordResetRequest) + mux.HandleFunc("POST /api/auth/password-reset/confirm", a.handlePasswordResetConfirm) mux.Handle("POST /api/auth/logout", a.operatorAuth(http.HandlerFunc(a.handleLogout))) mux.Handle("GET /api/auth/me", a.operatorAuth(http.HandlerFunc(a.handleAuthMe))) mux.Handle("PATCH /api/auth/profile", a.operatorAuth(http.HandlerFunc(a.handleUpdateProfile))) @@ -518,6 +555,10 @@ func (a *App) handleDeviceSubtree(w http.ResponseWriter, r *http.Request) { a.handleUpdateDeviceDNS(w, r) return } + if len(parts) == 4 && parts[3] == "transfer" && r.Method == http.MethodPost { + a.handleTransferDevice(w, r) + return + } if len(parts) == 4 && parts[3] == "metrics-history" && r.Method == http.MethodGet { a.handleListMetricSamples(w, r) return diff --git a/server/internal/store/security.go b/server/internal/store/security.go index 8a20a8a..19f243c 100644 --- a/server/internal/store/security.go +++ b/server/internal/store/security.go @@ -62,8 +62,10 @@ VALUES (?, ?, ?, 'admin', 0, ?, ?) return user, nil } -func (s *Store) CreateUser(ctx context.Context, username, passwordHash, role string) (model.User, error) { +func (s *Store) CreateUser(ctx context.Context, username, displayName, email, passwordHash, role string) (model.User, error) { username = strings.TrimSpace(username) + displayName = strings.TrimSpace(displayName) + email = strings.ToLower(strings.TrimSpace(email)) role = strings.ToLower(strings.TrimSpace(role)) if username == "" || passwordHash == "" || (role != "admin" && role != "user") { return model.User{}, errors.New("invalid user") @@ -73,13 +75,30 @@ func (s *Store) CreateUser(ctx context.Context, username, passwordHash, role str return model.User{}, err } now := nowText() - _, err = s.db.ExecContext(ctx, ` -INSERT INTO users (id, username, password_hash, role, disabled, created_at, updated_at) -VALUES (?, ?, ?, ?, 0, ?, ?) -`, id, username, passwordHash, role, now, now) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return model.User{}, err } + defer tx.Rollback() + if email != "" { + var inUse bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = ? COLLATE NOCASE)`, email).Scan(&inUse); err != nil { + return model.User{}, err + } + if inUse { + return model.User{}, errors.New("email is already in use") + } + } + _, err = tx.ExecContext(ctx, ` +INSERT INTO users (id, username, display_name, email, password_hash, role, disabled, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) +`, id, username, displayName, email, passwordHash, role, now, now) + if err != nil { + return model.User{}, err + } + if err := tx.Commit(); err != nil { + return model.User{}, err + } user, _, found, err := s.GetUserByUsername(ctx, username) if err != nil || !found { return model.User{}, err @@ -87,25 +106,33 @@ VALUES (?, ?, ?, ?, 0, ?, ?) return user, nil } -func (s *Store) UpdateUserSecurity(ctx context.Context, userID string, disabled *bool, passwordHash string) (model.User, bool, error) { - user, found, err := s.GetUserByID(ctx, userID) - if err != nil || !found { - return model.User{}, found, err - } - if disabled != nil && *disabled && user.Role == "admin" { - var activeAdmins int - if err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&activeAdmins); err != nil { - return model.User{}, false, err - } - if activeAdmins <= 1 && !user.Disabled { - return model.User{}, false, errors.New("cannot disable the last active administrator") - } +func (s *Store) UpdateUserSecurity(ctx context.Context, userID string, disabled *bool, passwordHash, role string) (model.User, bool, error) { + role = strings.ToLower(strings.TrimSpace(role)) + if role != "" && role != "admin" && role != "user" { + return model.User{}, false, errors.New("invalid user role") } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return model.User{}, false, err } defer tx.Rollback() + var currentRole string + var currentDisabled int + if err := tx.QueryRowContext(ctx, `SELECT role, disabled FROM users WHERE id = ?`, userID).Scan(¤tRole, ¤tDisabled); errors.Is(err, sql.ErrNoRows) { + return model.User{}, false, nil + } else if err != nil { + return model.User{}, false, err + } + removesActiveAdmin := currentRole == "admin" && currentDisabled == 0 && ((disabled != nil && *disabled) || role == "user") + if removesActiveAdmin { + var activeAdmins int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM users WHERE role = 'admin' AND disabled = 0`).Scan(&activeAdmins); err != nil { + return model.User{}, false, err + } + if activeAdmins <= 1 { + return model.User{}, false, errors.New("cannot disable or demote the last active administrator") + } + } if disabled != nil { if _, err := tx.ExecContext(ctx, `UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?`, *disabled, nowText(), userID); err != nil { return model.User{}, false, err @@ -116,7 +143,12 @@ func (s *Store) UpdateUserSecurity(ctx context.Context, userID string, disabled return model.User{}, false, err } } - if disabled != nil && *disabled || passwordHash != "" { + if role != "" { + if _, err := tx.ExecContext(ctx, `UPDATE users SET role = ?, updated_at = ? WHERE id = ?`, role, nowText(), userID); err != nil { + return model.User{}, false, err + } + } + if disabled != nil && *disabled || passwordHash != "" || role != "" { if _, err := tx.ExecContext(ctx, `DELETE FROM operator_sessions WHERE user_id = ?`, userID); err != nil { return model.User{}, false, err } @@ -207,9 +239,24 @@ func (s *Store) RevokeOperatorSession(ctx context.Context, tokenHash string) err } func (s *Store) UpdateUserProfile(ctx context.Context, userID, displayName, email string) (model.User, bool, error) { - result, err := s.db.ExecContext(ctx, ` + email = strings.ToLower(strings.TrimSpace(email)) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return model.User{}, false, err + } + defer tx.Rollback() + if email != "" { + var inUse bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE email = ? COLLATE NOCASE AND id != ?)`, email, userID).Scan(&inUse); err != nil { + return model.User{}, false, err + } + if inUse { + return model.User{}, false, errors.New("email is already in use") + } + } + result, err := tx.ExecContext(ctx, ` UPDATE users SET display_name = ?, email = ?, updated_at = ? WHERE id = ? -`, strings.TrimSpace(displayName), strings.ToLower(strings.TrimSpace(email)), nowText(), userID) +`, strings.TrimSpace(displayName), email, nowText(), userID) if err != nil { return model.User{}, false, err } @@ -217,6 +264,9 @@ UPDATE users SET display_name = ?, email = ?, updated_at = ? WHERE id = ? if err != nil || changed == 0 { return model.User{}, false, err } + if err := tx.Commit(); err != nil { + return model.User{}, false, err + } return s.GetUserByID(ctx, userID) } @@ -245,6 +295,84 @@ func (s *Store) RevokeUserSessions(ctx context.Context, userID string) error { return err } +func (s *Store) GetUserForPasswordReset(ctx context.Context, identifier string) (model.User, bool, error) { + identifier = strings.ToLower(strings.TrimSpace(identifier)) + rows, err := s.db.QueryContext(ctx, ` +SELECT id, username, display_name, email, role, disabled, created_at, updated_at +FROM users +WHERE disabled = 0 AND (username = ? COLLATE NOCASE OR (email != '' AND email = ? COLLATE NOCASE)) +LIMIT 2 +`, identifier, identifier) + if err != nil { + return model.User{}, false, err + } + defer rows.Close() + users := make([]model.User, 0, 2) + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + return model.User{}, false, err + } + users = append(users, user) + } + if err := rows.Err(); err != nil { + return model.User{}, false, err + } + if len(users) != 1 || users[0].Email == "" { + return model.User{}, false, nil + } + return users[0], true, nil +} + +func (s *Store) CreatePasswordReset(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM password_reset_tokens WHERE user_id = ?`, userID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO password_reset_tokens (token_hash, user_id, expires_at, created_at) +VALUES (?, ?, ?, ?) +`, tokenHash, userID, expiresAt.UTC().Format(time.RFC3339Nano), nowText()); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) ResetPassword(ctx context.Context, tokenHash, passwordHash string) (bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, err + } + defer tx.Rollback() + var userID string + if err := tx.QueryRowContext(ctx, ` +SELECT t.user_id FROM password_reset_tokens t +JOIN users u ON u.id = t.user_id +WHERE t.token_hash = ? AND julianday(t.expires_at) > julianday(?) AND u.disabled = 0 +`, tokenHash, nowText()).Scan(&userID); errors.Is(err, sql.ErrNoRows) { + return false, nil + } else if err != nil { + return false, err + } + if _, err := tx.ExecContext(ctx, `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? AND disabled = 0`, passwordHash, nowText(), userID); err != nil { + return false, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM operator_sessions WHERE user_id = ?`, userID); err != nil { + return false, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM password_reset_tokens WHERE user_id = ?`, userID); err != nil { + return false, err + } + if err := tx.Commit(); err != nil { + return false, err + } + return true, nil +} + func (s *Store) CreateEnrollmentGrant(ctx context.Context, userID, dnsLabel, tokenHash string, expiresAt time.Time) (model.EnrollmentGrant, error) { var activeGrants int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM enrollment_grants WHERE user_id = ? AND used_at IS NULL AND julianday(expires_at) > julianday(?)`, userID, nowText()).Scan(&activeGrants); err != nil { @@ -405,6 +533,58 @@ func (s *Store) UpdateDeviceDNSLabel(ctx context.Context, deviceID, dnsLabel str return s.GetDevice(ctx, deviceID) } +func (s *Store) TransferDevice(ctx context.Context, deviceID, targetUserID, requesterUserID string, admin bool) (model.Device, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return model.Device{}, false, err + } + defer tx.Rollback() + var targetAvailable bool + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM users WHERE id = ? AND disabled = 0)`, targetUserID).Scan(&targetAvailable); err != nil { + return model.Device{}, false, err + } + if !targetAvailable { + return model.Device{}, false, errors.New("target user is unavailable") + } + query := `UPDATE devices SET owner_user_id = ? WHERE id = ? AND owner_user_id != ?` + args := []any{targetUserID, deviceID, targetUserID} + if !admin { + query += ` AND owner_user_id = ?` + args = append(args, requesterUserID) + } + result, err := tx.ExecContext(ctx, query, args...) + if err != nil { + return model.Device{}, false, err + } + if affected, _ := result.RowsAffected(); affected == 0 { + return model.Device{}, false, nil + } + if _, err := tx.ExecContext(ctx, `DELETE FROM device_access_grants WHERE device_id = ?`, deviceID); err != nil { + return model.Device{}, false, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM device_access_sessions WHERE device_id = ?`, deviceID); err != nil { + return model.Device{}, false, err + } + now := nowText() + if _, err := tx.ExecContext(ctx, ` +UPDATE commands SET status = 'cancelled', cancelled_at = ? +WHERE device_id = ? AND status IN ('queued', 'claimed') + AND id IN (SELECT command_id FROM remote_sessions WHERE device_id = ?) +`, now, deviceID, deviceID); err != nil { + return model.Device{}, false, err + } + if _, err := tx.ExecContext(ctx, ` +UPDATE remote_sessions SET status = 'closed', closed_at = ?, updated_at = ? +WHERE device_id = ? AND status IN ('requested', 'queued', 'active') +`, now, now, deviceID); err != nil { + return model.Device{}, false, err + } + if err := tx.Commit(); err != nil { + return model.Device{}, false, err + } + return s.GetDevice(ctx, deviceID) +} + func (s *Store) CreateDeviceAccessGrant(ctx context.Context, tokenHash, userID, deviceID, remoteSessionID string, expiresAt time.Time) error { var activeGrants int if err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM device_access_grants WHERE user_id = ? AND julianday(expires_at) > julianday(?)`, userID, nowText()).Scan(&activeGrants); err != nil { @@ -492,6 +672,7 @@ func (s *Store) PurgeExpiredSecurityData(ctx context.Context) error { now := nowText() for _, statement := range []string{ `DELETE FROM operator_sessions WHERE julianday(expires_at) <= julianday(?)`, + `DELETE FROM password_reset_tokens WHERE julianday(expires_at) <= julianday(?)`, `DELETE FROM enrollment_grants WHERE julianday(expires_at) <= julianday(?)`, `DELETE FROM device_access_grants WHERE julianday(expires_at) <= julianday(?)`, `DELETE FROM device_access_sessions WHERE julianday(expires_at) <= julianday(?)`, diff --git a/server/internal/store/sqlite.go b/server/internal/store/sqlite.go index 37fc4c0..7c4e868 100644 --- a/server/internal/store/sqlite.go +++ b/server/internal/store/sqlite.go @@ -214,6 +214,14 @@ CREATE TABLE IF NOT EXISTS operator_sessions ( FOREIGN KEY(user_id) REFERENCES users(id) ); +CREATE TABLE IF NOT EXISTS password_reset_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) +); + CREATE TABLE IF NOT EXISTS enrollment_grants ( id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, @@ -291,6 +299,7 @@ CREATE TABLE IF NOT EXISTS device_access_sessions ( `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_dns_label ON devices(dns_label) WHERE dns_label != ''`, `CREATE INDEX IF NOT EXISTS idx_devices_owner_created_at ON devices(owner_user_id, created_at)`, `CREATE INDEX IF NOT EXISTS idx_operator_sessions_user_expires ON operator_sessions(user_id, expires_at)`, + `CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_expires ON password_reset_tokens(user_id, expires_at)`, `CREATE INDEX IF NOT EXISTS idx_enrollment_grants_user_created ON enrollment_grants(user_id, created_at)`, `CREATE INDEX IF NOT EXISTS idx_device_access_sessions_device_expires ON device_access_sessions(device_id, expires_at)`, `CREATE INDEX IF NOT EXISTS idx_remote_sessions_device_created_at ON remote_sessions(device_id, created_at)`, diff --git a/web/app.js b/web/app.js index e9fdd87..5b56ac7 100644 --- a/web/app.js +++ b/web/app.js @@ -2,6 +2,8 @@ const state = { username: "", user: null, devices: [], + users: [], + passwordResetToken: "", selectedDeviceId: null, deviceTab: "overview", filter: "all", @@ -36,6 +38,18 @@ const els = { loginUsername: document.querySelector("#loginUsername"), loginPassword: document.querySelector("#loginPassword"), loginError: document.querySelector("#loginError"), + forgotPasswordBtn: document.querySelector("#forgotPasswordBtn"), + forgotPasswordDialog: document.querySelector("#forgotPasswordDialog"), + forgotPasswordForm: document.querySelector("#forgotPasswordForm"), + closeForgotPasswordBtn: document.querySelector("#closeForgotPasswordBtn"), + passwordResetIdentifier: document.querySelector("#passwordResetIdentifier"), + forgotPasswordMessage: document.querySelector("#forgotPasswordMessage"), + passwordResetDialog: document.querySelector("#passwordResetDialog"), + passwordResetForm: document.querySelector("#passwordResetForm"), + closePasswordResetBtn: document.querySelector("#closePasswordResetBtn"), + resetNewPassword: document.querySelector("#resetNewPassword"), + resetConfirmPassword: document.querySelector("#resetConfirmPassword"), + passwordResetMessage: document.querySelector("#passwordResetMessage"), appShell: document.querySelector("#appShell"), operatorName: document.querySelector("#operatorName"), fleetNavBtn: document.querySelector("#fleetNavBtn"), @@ -53,7 +67,9 @@ const els = { createUserDialog: document.querySelector("#createUserDialog"), createUserForm: document.querySelector("#createUserForm"), newUsername: document.querySelector("#newUsername"), + newUserEmail: document.querySelector("#newUserEmail"), newUserPassword: document.querySelector("#newUserPassword"), + newUserRole: document.querySelector("#newUserRole"), cancelCreateUserBtn: document.querySelector("#cancelCreateUserBtn"), deviceList: document.querySelector("#deviceList"), fleetView: document.querySelector("#fleetView"), @@ -180,6 +196,10 @@ const els = { clearCommandsBtn: document.querySelector("#clearCommandsBtn"), clearAuditBtn: document.querySelector("#clearAuditBtn"), deleteDeviceBtn: document.querySelector("#deleteDeviceBtn"), + deviceTransferForm: document.querySelector("#deviceTransferForm"), + transferUsername: document.querySelector("#transferUsername"), + transferPassword: document.querySelector("#transferPassword"), + transferMessage: document.querySelector("#transferMessage"), toastRegion: document.querySelector("#toastRegion"), profileDialog: document.querySelector("#profileDialog"), closeProfileBtn: document.querySelector("#closeProfileBtn"), @@ -197,6 +217,8 @@ const els = { confirmPassword: document.querySelector("#confirmPassword"), passwordMessage: document.querySelector("#passwordMessage"), logoutAllBtn: document.querySelector("#logoutAllBtn"), + userManagementSection: document.querySelector("#userManagementSection"), + userList: document.querySelector("#userList"), luciStateDialog: document.querySelector("#luciStateDialog"), luciStateCode: document.querySelector("#luciStateCode"), luciStateTitle: document.querySelector("#luciStateTitle"), @@ -314,6 +336,7 @@ function showApp(user) { els.profileAvatar.textContent = initial; document.querySelector(".operator-avatar").textContent = initial; els.addUserBtn.classList.toggle("is-hidden", !user || user.role !== "admin"); + els.userManagementSection.classList.toggle("is-hidden", !user || user.role !== "admin"); els.loginView.classList.add("is-hidden"); els.loginView.hidden = true; els.loginView.setAttribute("aria-hidden", "true"); @@ -1356,6 +1379,11 @@ function showProfile() { setFormMessage(els.passwordMessage, ""); els.passwordForm.reset(); if (!els.profileDialog.open) els.profileDialog.showModal(); + if (state.user && state.user.role === "admin") { + loadUsers().catch((error) => { + els.userList.innerHTML = inlineStateMarkup("Не удалось загрузить пользователей", error.message); + }); + } } function closeProfile() { @@ -1404,6 +1432,90 @@ async function logoutAll() { showLogin("Все сессии завершены. Войдите снова."); } +async function loadUsers() { + const response = await api("/api/users"); + state.users = response.users || []; + renderUsers(); +} + +function renderUsers() { + els.userList.innerHTML = ""; + for (const user of state.users) { + const ownAccount = state.user && user.id === state.user.id; + const row = document.createElement("article"); + row.className = `user-row${user.disabled ? " is-disabled" : ""}`; + row.innerHTML = ` +
+ ${escapeHtml((user.display_name || user.username || "U").charAt(0).toUpperCase())} +
${escapeHtml(user.display_name || user.username)}${escapeHtml(user.username)}${user.email ? ` · ${escapeHtml(user.email)}` : " · e-mail не задан"}
+
+ +
+ + +
+ `; + row.querySelector("[data-user-role]").addEventListener("change", (event) => updateManagedUser(user, { role: event.target.value })); + row.querySelector("[data-user-disabled]").addEventListener("click", () => updateManagedUser(user, { disabled: !user.disabled })); + row.querySelector("[data-user-password]").addEventListener("click", () => resetManagedUserPassword(user)); + els.userList.appendChild(row); + } +} + +async function updateManagedUser(user, changes) { + try { + await api(`/api/users/${encodeURIComponent(user.id)}`, { method: "PATCH", body: JSON.stringify(changes) }); + await loadUsers(); + notify(`Аккаунт ${user.username} обновлён`, "success"); + } catch (error) { + await loadUsers(); + reportError(error); + } +} + +function resetManagedUserPassword(user) { + const password = window.prompt(`Новый временный пароль для ${user.username} (минимум 12 символов)`); + if (password === null) return; + updateManagedUser(user, { password }).catch(reportError); +} + +async function requestPasswordReset() { + setFormMessage(els.forgotPasswordMessage, "Отправляем…"); + await api("/api/auth/password-reset/request", { + method: "POST", + body: JSON.stringify({ identifier: els.passwordResetIdentifier.value.trim() }), + }); + els.forgotPasswordForm.reset(); + setFormMessage(els.forgotPasswordMessage, "Если e-mail восстановления настроен, ссылка уже отправлена.", "success"); +} + +async function confirmPasswordReset() { + setFormMessage(els.passwordResetMessage, ""); + if (els.resetNewPassword.value !== els.resetConfirmPassword.value) { + setFormMessage(els.passwordResetMessage, "Пароли не совпадают", "error"); + return; + } + await api("/api/auth/password-reset/confirm", { + method: "POST", + body: JSON.stringify({ token: state.passwordResetToken, new_password: els.resetNewPassword.value }), + }); + state.passwordResetToken = ""; + history.replaceState(null, "", `${location.pathname}${location.search}`); + els.passwordResetForm.reset(); + setFormMessage(els.passwordResetMessage, "Пароль изменён. Теперь можно войти.", "success"); +} + +function openPasswordResetFromURL() { + const match = location.hash.match(/^#password-reset=(.+)$/); + if (!match) return; + try { + state.passwordResetToken = decodeURIComponent(match[1]); + } catch { + state.passwordResetToken = ""; + } + if (state.passwordResetToken) els.passwordResetDialog.showModal(); +} + async function runLuCIPrimaryAction() { const activeSession = state.remoteSessions.find((session) => session.status === "active" && session.luci_port); if (els.luciStateDialog.open) els.luciStateDialog.close(); @@ -1992,6 +2104,23 @@ async function deleteSelectedDevice() { notify("Устройство удалено", "success"); } +async function transferSelectedDevice() { + const device = currentDevice(); + if (!device) return; + const targetUsername = els.transferUsername.value.trim(); + if (!window.confirm(`Передать роутер «${deviceDisplayName(device)}» пользователю ${targetUsername}? Текущий удалённый доступ будет закрыт.`)) return; + setFormMessage(els.transferMessage, "Передаём…"); + await api(`/api/devices/${encodeURIComponent(device.id)}/transfer`, { + method: "POST", + body: JSON.stringify({ target_username: targetUsername, current_password: els.transferPassword.value }), + }); + els.deviceTransferForm.reset(); + state.selectedDeviceId = null; + await loadDevices(); + render(); + notify(`Роутер передан пользователю ${targetUsername}`, "success"); +} + function diagnosticCommand(name) { const serverHost = window.location.hostname || "10.10.10.2"; switch (name) { @@ -2091,10 +2220,11 @@ async function createUserAccount() { const password = els.newUserPassword.value; await api("/api/users", { method: "POST", - body: JSON.stringify({ username, password, role: "user" }), + body: JSON.stringify({ username, email: els.newUserEmail.value.trim(), password, role: els.newUserRole.value }), }); els.createUserDialog.close(); els.createUserForm.reset(); + if (els.profileDialog.open) await loadUsers(); notify(`Пользователь ${username} создан`, "success"); } @@ -2113,6 +2243,20 @@ els.loginForm.addEventListener("submit", (event) => { event.preventDefault(); login().catch((error) => showLogin(error.message)); }); +els.forgotPasswordBtn.addEventListener("click", () => { + setFormMessage(els.forgotPasswordMessage, ""); + els.forgotPasswordDialog.showModal(); +}); +els.closeForgotPasswordBtn.addEventListener("click", () => els.forgotPasswordDialog.close()); +els.forgotPasswordForm.addEventListener("submit", (event) => { + event.preventDefault(); + requestPasswordReset().catch((error) => setFormMessage(els.forgotPasswordMessage, error.message, "error")); +}); +els.closePasswordResetBtn.addEventListener("click", () => els.passwordResetDialog.close()); +els.passwordResetForm.addEventListener("submit", (event) => { + event.preventDefault(); + confirmPasswordReset().catch((error) => setFormMessage(els.passwordResetMessage, error.message, "error")); +}); els.logoutBtn.addEventListener("click", () => logout().catch(reportError)); els.fleetNavBtn.addEventListener("click", showFleet); els.problemsNavBtn.addEventListener("click", () => openDeviceArea("overview", "#alertList", "problems").catch(reportError)); @@ -2194,6 +2338,10 @@ els.clearAlertsBtn.addEventListener("click", () => clearDeviceAlerts().catch(rep els.clearCommandsBtn.addEventListener("click", () => clearDeviceCommands().catch(reportError)); els.clearAuditBtn.addEventListener("click", () => clearDeviceAudit().catch(reportError)); els.deleteDeviceBtn.addEventListener("click", () => deleteSelectedDevice().catch(reportError)); +els.deviceTransferForm.addEventListener("submit", (event) => { + event.preventDefault(); + transferSelectedDevice().catch((error) => setFormMessage(els.transferMessage, error.message, "error")); +}); els.sendPackageCommandBtn.addEventListener("click", () => sendPackageCommand().catch(reportError)); els.createRemoteSessionBtn.addEventListener("click", () => createRemoteSession().catch(reportError)); els.uciBackupBtn.addEventListener("click", () => sendUciCommand("uci_backup").catch(reportError)); @@ -2295,6 +2443,7 @@ els.bulkCommandType.addEventListener("change", () => { els.bulkCommandTarget.disabled = els.bulkCommandType.value === "pkg_list_installed"; }); +openPasswordResetFromURL(); checkHealth(); checkSession(); setInterval(() => { diff --git a/web/index.html b/web/index.html index cec7206..9d379aa 100644 --- a/web/index.html +++ b/web/index.html @@ -10,7 +10,7 @@ - +
@@ -72,6 +72,7 @@ + @@ -496,6 +497,21 @@
+
+
+
+

Передача роутера

+

Новый владелец получит управление устройством. Текущие LuCI-сессии будут закрыты.

+
+
+
+ + + +

+
+
+

UCI configuration

@@ -763,10 +779,18 @@ Логин + +
@@ -805,6 +829,10 @@

+
@@ -812,6 +840,26 @@
+ + + + + + + +
- + diff --git a/web/styles.css b/web/styles.css index d3d4f78..cb1023d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -3262,7 +3262,7 @@ select:focus-visible, } } -@media (max-width: 1200px) { +@media (max-width: 1500px) { .fleet-view { grid-template-columns: minmax(0, 1fr); } .fleet-summary, .fleet-toolbar, @@ -3354,3 +3354,169 @@ select:focus-visible, } .client-filter { width: auto; flex: 0 0 auto; } } + +.profile-dialog { + width: min(760px, calc(100vw - 24px)); + max-width: min(760px, calc(100vw - 24px)); +} + +.device-transfer-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) auto; + gap: 12px; + align-items: end; +} + +.device-transfer-form label, +.user-row label { + display: grid; + min-width: 0; + gap: 6px; + color: var(--muted); + font-size: 12px; +} + +.device-transfer-form .form-message { + grid-column: 1 / -1; +} + +.user-list { + display: grid; + gap: 9px; +} + +.user-row { + display: grid; + grid-template-columns: minmax(220px, 1fr) 150px auto; + gap: 12px; + align-items: center; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-raised); + padding: 12px; +} + +.user-row.is-disabled { + opacity: .7; +} + +.user-row-identity { + display: flex; + min-width: 0; + gap: 10px; + align-items: center; +} + +.user-row-identity .profile-avatar { + width: 40px; + height: 40px; + flex-basis: 40px; + border-radius: 12px; + font-size: 16px; +} + +.user-row-identity > div { + display: grid; + min-width: 0; + gap: 3px; +} + +.user-row-identity small { + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-row-actions { + display: flex; + gap: 7px; +} + +@media (max-width: 1200px) { + .uci-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .uci-grid .check-row { + grid-column: 1 / -1; + } + + .network-table-head { + display: none; + } + + .network-list { + display: grid; + gap: 10px; + overflow: visible; + } + + .network-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; + min-width: 0; + border: 1px solid var(--line); + border-radius: 13px; + background: var(--surface-raised); + padding: 14px; + } + + .network-row > * { + display: grid; + min-width: 0; + gap: 3px; + overflow-wrap: anywhere; + } + + .network-row > :nth-child(1)::before { content: "Интерфейс"; } + .network-row > :nth-child(2)::before { content: "Адреса"; } + .network-row > :nth-child(3)::before { content: "Получено"; } + .network-row > :nth-child(4)::before { content: "Передано"; } + .network-row > :nth-child(5)::before { content: "Ошибки"; } + .network-row > *::before { + color: var(--muted); + font-size: 10px; + font-weight: 750; + letter-spacing: .04em; + text-transform: uppercase; + } + + .alert-row { + grid-template-columns: minmax(0, 1fr) repeat(3, auto); + min-width: 0; + } + + .alert-row > strong, + .alert-row > .alert-detail { + grid-column: 1 / -1; + } + + .device-transfer-form, + .user-row { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .device-transfer-form button, + .user-row-identity, + .user-row-actions { + grid-column: 1 / -1; + } +} + +@media (max-width: 720px) { + .device-transfer-form, + .user-row { + grid-template-columns: 1fr; + } + + .device-transfer-form > *, + .user-row > * { + grid-column: 1; + } + + .user-row-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } +}