diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index cba89ea..ed0e34c 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -18,6 +18,8 @@ import ( "golang.org/x/crypto/hkdf" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/archivmail/config" "github.com/archivmail/internal/api" "github.com/archivmail/internal/audit" @@ -27,10 +29,12 @@ import ( "github.com/archivmail/internal/index" "github.com/archivmail/internal/labelstore" ldapcfg "github.com/archivmail/internal/ldapconfig" + "github.com/archivmail/internal/mailer" pop3store "github.com/archivmail/internal/pop3" "github.com/archivmail/internal/smtpd" "github.com/archivmail/internal/storage" tenantstore "github.com/archivmail/internal/tenantstore" + "github.com/archivmail/internal/tokenstore" "github.com/archivmail/internal/userstore" "github.com/archivmail/pkg/mailparser" ) @@ -179,6 +183,26 @@ func main() { srv.SetVersion(AppVersion, Modules) srv.SetGlobalRetentionDays(cfg.Storage.RetentionDays) + // PROJ-28: Self-Service Onboarding — mailer + token store + FQDN + mlr := mailer.New(cfg.SMTPOut) + srv.SetMailer(mlr) + srv.SetFQDN(cfg.Server.FQDN) + if cfg.Server.FQDN == "" { + logger.Warn("server.fqdn not set — signup/reset links will not work (PROJ-28)") + } + tokenPool, err := pgxpool.New(context.Background(), cfg.Database.DSN()) + if err != nil { + logger.Error("token store pool failed", "err", err) + os.Exit(1) + } + defer tokenPool.Close() + tokenSt, err := tokenstore.New(tokenPool) + if err != nil { + logger.Error("token store init failed", "err", err) + os.Exit(1) + } + srv.SetTokenStore(tokenSt) + bind := cfg.API.Bind if bind == "" { bind = fmt.Sprintf(":%d", cfg.Server.APIPort) diff --git a/internal/api/invite_handlers.go b/internal/api/invite_handlers.go new file mode 100644 index 0000000..55bd68a --- /dev/null +++ b/internal/api/invite_handlers.go @@ -0,0 +1,106 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/archivmail/internal/audit" + "github.com/archivmail/internal/mailer" + "github.com/archivmail/internal/tokenstore" + "github.com/archivmail/internal/userstore" +) + +// handleCreateInvite generates an invite token for a tenant and optionally emails it. +// POST /api/admin/invite — domain_admin+ (PROJ-28) +func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request) { + var body struct { + TenantID int64 `json:"tenant_id"` // superadmin: arbitrary; domain_admin: own tenant + Email string `json:"email"` // optional: send invite by email + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + + sess := sessionFromCtx(r.Context()) + + // domain_admin may only invite to their own tenant + if sess.Role != userstore.RoleSuperAdmin { + tenantID := tenantFromCtx(r.Context()) + if tenantID == nil || *tenantID != body.TenantID { + writeError(w, http.StatusForbidden, "forbidden") + return + } + } + + tid := body.TenantID + token, err := s.tokenStore.Create(r.Context(), tokenstore.TypeInvite, nil, &tid, 72*time.Hour) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + inviteURL := "" + if s.fqdn != "" { + inviteURL = "https://" + s.fqdn + "/signup?invite=" + token + } + + // Optionally send invite email + if body.Email != "" && s.mailer.IsConfigured() { + tenantName := strconv.FormatInt(body.TenantID, 10) + if s.tenantStore != nil { + if t, err := s.tenantStore.Get(r.Context(), body.TenantID); err == nil { + tenantName = t.Name + } + } + go func() { + html := mailer.InviteHTML(s.fqdn, token, tenantName) + txt := mailer.InviteText(s.fqdn, token, tenantName) + _ = s.mailer.Send(body.Email, "Einladung zu archivmail", html, txt) + }() + } + + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "invite_created", + Username: sess.Username, + IPAddress: s.remoteIP(r), + Success: true, + }) + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "token": token, + "invite_url": inviteURL, + }) +} + +// handleCheckInvite validates an invite token and returns the tenant name. +// GET /api/auth/invite?token=... (PROJ-28) +func (s *Server) handleCheckInvite(w http.ResponseWriter, r *http.Request) { + plain := r.URL.Query().Get("token") + if plain == "" { + writeError(w, http.StatusBadRequest, "token required") + return + } + + tok, err := s.tokenStore.Peek(r.Context(), tokenstore.TypeInvite, plain) + if err != nil { + writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Einladungslink") + return + } + + tenantName := "" + if tok.TenantID != nil && s.tenantStore != nil { + if t, err := s.tenantStore.Get(r.Context(), *tok.TenantID); err == nil { + tenantName = t.Name + } + } + + writeJSON(w, http.StatusOK, map[string]interface{}{ + "valid": true, + "tenant_name": tenantName, + }) +} diff --git a/internal/api/onboarding_handlers.go b/internal/api/onboarding_handlers.go new file mode 100644 index 0000000..ead2c8d --- /dev/null +++ b/internal/api/onboarding_handlers.go @@ -0,0 +1,234 @@ +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/archivmail/internal/audit" + "github.com/archivmail/internal/mailer" + "github.com/archivmail/internal/tokenstore" + "github.com/archivmail/internal/userstore" +) + +// handleSignup creates an inactive account and sends a verification email. +// POST /api/auth/signup (PROJ-28) +func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) { + if !s.mailer.IsConfigured() { + writeError(w, http.StatusServiceUnavailable, "E-Mail-Versand nicht konfiguriert (smtp_out)") + return + } + + var body struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Invite string `json:"invite"` // optional invite token + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if body.Username == "" || body.Email == "" || body.Password == "" { + writeError(w, http.StatusBadRequest, "username, email and password required") + return + } + if len(body.Password) < 8 { + writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + + req := userstore.CreateUserRequest{ + Username: body.Username, + Email: body.Email, + Password: body.Password, + Role: userstore.RoleUser, + } + + // If invite token provided, attach tenant + if body.Invite != "" && s.tokenStore != nil { + tok, err := s.tokenStore.Peek(r.Context(), tokenstore.TypeInvite, body.Invite) + if err != nil { + writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Einladungslink") + return + } + req.TenantID = tok.TenantID + } + + u, err := s.users.CreateInactive(req) + if err != nil { + // Avoid info-leakage: same response for duplicate email/username + writeJSON(w, http.StatusOK, map[string]string{"message": "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet."}) + return + } + + // Generate verification token + uid := u.ID + token, err := s.tokenStore.Create(r.Context(), tokenstore.TypeVerify, &uid, nil, 24*time.Hour) + if err != nil { + writeError(w, http.StatusInternalServerError, "token error") + return + } + + // If invite token was used — consume it now + if body.Invite != "" { + _, _ = s.tokenStore.Use(r.Context(), tokenstore.TypeInvite, body.Invite) + } + + // Send verification email + go func() { + html := mailer.VerifyEmailHTML(s.fqdn, token, u.Username) + txt := mailer.VerifyEmailText(s.fqdn, token, u.Username) + _ = s.mailer.Send(u.Email, "archivmail – E-Mail bestätigen", html, txt) + }() + + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "signup", + Username: u.Username, + IPAddress: s.remoteIP(r), + Success: true, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet."}) +} + +// handleVerifyEmail activates an account via a verification token. +// GET /api/auth/verify?token=... (PROJ-28) +func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) { + plain := r.URL.Query().Get("token") + if plain == "" { + writeError(w, http.StatusBadRequest, "token required") + return + } + + tok, err := s.tokenStore.Use(r.Context(), tokenstore.TypeVerify, plain) + if err != nil { + writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Link") + return + } + + if tok.UserID == nil { + writeError(w, http.StatusBadRequest, "invalid token") + return + } + + if err := s.users.Activate(r.Context(), *tok.UserID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "email_verified", + IPAddress: s.remoteIP(r), + Success: true, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden."}) +} + +// handleForgotPassword sends a password-reset email. +// POST /api/auth/forgot-password (PROJ-28) +func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) { + // Always respond with same message to avoid info-leakage + const msg = "Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet." + + if !s.mailer.IsConfigured() { + writeError(w, http.StatusServiceUnavailable, "E-Mail-Versand nicht konfiguriert") + return + } + + var body struct { + Email string `json:"email"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Email == "" { + writeJSON(w, http.StatusOK, map[string]string{"message": msg}) + return + } + + u, err := s.users.GetByEmail(r.Context(), body.Email) + if err != nil || u == nil { + writeJSON(w, http.StatusOK, map[string]string{"message": msg}) + return + } + + // LDAP users cannot reset password here + if u.Source != "local" { + writeJSON(w, http.StatusOK, map[string]string{"message": msg}) + return + } + + uid := u.ID + token, err := s.tokenStore.Create(r.Context(), tokenstore.TypeReset, &uid, nil, 1*time.Hour) + if err != nil { + writeJSON(w, http.StatusOK, map[string]string{"message": msg}) + return + } + + go func() { + html := mailer.ResetPasswordHTML(s.fqdn, token, u.Username) + txt := mailer.ResetPasswordText(s.fqdn, token, u.Username) + _ = s.mailer.Send(u.Email, "archivmail – Passwort zurücksetzen", html, txt) + }() + + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "password_reset_requested", + Username: u.Username, + IPAddress: s.remoteIP(r), + Success: true, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"message": msg}) +} + +// handleResetPassword sets a new password via a reset token. +// POST /api/auth/reset-password (PROJ-28) +func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { + var body struct { + Token string `json:"token"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + if body.Token == "" || body.Password == "" { + writeError(w, http.StatusBadRequest, "token and password required") + return + } + if len(body.Password) < 8 { + writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + + tok, err := s.tokenStore.Use(r.Context(), tokenstore.TypeReset, body.Token) + if err != nil { + writeError(w, http.StatusBadRequest, "ungültiger oder abgelaufener Link") + return + } + + if tok.UserID == nil { + writeError(w, http.StatusBadRequest, "invalid token") + return + } + + if err := s.users.SetPassword(r.Context(), *tok.UserID, body.Password); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + if s.audlog != nil { + s.audlog.Log(audit.Entry{ + EventType: "password_reset_done", + IPAddress: s.remoteIP(r), + Success: true, + }) + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "Passwort erfolgreich zurückgesetzt."}) +} diff --git a/internal/api/server.go b/internal/api/server.go index d4d30dd..12217d5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -18,10 +18,12 @@ import ( "github.com/archivmail/internal/index" "github.com/archivmail/internal/labelstore" ldapcfg "github.com/archivmail/internal/ldapconfig" + "github.com/archivmail/internal/mailer" pop3store "github.com/archivmail/internal/pop3" "github.com/archivmail/internal/smtpd" "github.com/archivmail/internal/storage" "github.com/archivmail/internal/tenantstore" + "github.com/archivmail/internal/tokenstore" "github.com/archivmail/internal/userstore" ) @@ -81,6 +83,9 @@ type Server struct { appVersion string moduleVersions map[string]string globalRetentionDays int // from storage config (PROJ-34) + mailer *mailer.Mailer + tokenStore *tokenstore.Store + fqdn string // from server.fqdn config (PROJ-28) } // SetSMTPDaemon wires the SMTP daemon into the API server after construction. @@ -117,6 +122,21 @@ func (s *Server) SetGlobalRetentionDays(days int) { s.globalRetentionDays = days } +// SetMailer wires the SMTP-Out mailer into the API server (PROJ-28). +func (s *Server) SetMailer(m *mailer.Mailer) { + s.mailer = m +} + +// SetTokenStore wires the token store into the API server (PROJ-28). +func (s *Server) SetTokenStore(ts *tokenstore.Store) { + s.tokenStore = ts +} + +// SetFQDN wires the server FQDN for link generation (PROJ-28). +func (s *Server) SetFQDN(fqdn string) { + s.fqdn = fqdn +} + // New creates and wires up a new API server. func New( cfg config.APIConfig, @@ -157,6 +177,14 @@ func (s *Server) routes() { s.mux.HandleFunc("POST /api/auth/login", s.handleLogin) s.mux.HandleFunc("GET /api/auth/me", s.auth(s.handleMe)) s.mux.HandleFunc("POST /api/auth/logout", s.auth(s.handleLogout)) + + // PROJ-28: Self-Service Onboarding + s.mux.HandleFunc("POST /api/auth/signup", s.handleSignup) + s.mux.HandleFunc("GET /api/auth/verify", s.handleVerifyEmail) + s.mux.HandleFunc("POST /api/auth/forgot-password", s.handleForgotPassword) + s.mux.HandleFunc("POST /api/auth/reset-password", s.handleResetPassword) + s.mux.HandleFunc("GET /api/auth/invite", s.handleCheckInvite) + s.mux.HandleFunc("POST /api/admin/invite", s.authAdmin(s.handleCreateInvite)) s.mux.HandleFunc("GET /api/users", s.authAdmin(s.handleListUsers)) s.mux.HandleFunc("POST /api/users", s.authAdmin(s.handleCreateUser)) s.mux.HandleFunc("PATCH /api/users/{id}", s.authAdmin(s.handleUpdateUser)) diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go new file mode 100644 index 0000000..44ea185 --- /dev/null +++ b/internal/mailer/mailer.go @@ -0,0 +1,152 @@ +// Package mailer sends transactional emails via an outbound SMTP relay. +// It uses only the Go standard library (net/smtp + crypto/tls) to avoid +// adding external dependencies. +package mailer + +import ( + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" + "time" + + "github.com/archivmail/config" +) + +// Mailer sends transactional emails via the configured SMTP-Out relay. +type Mailer struct { + cfg config.SMTPOutConfig +} + +// New creates a Mailer from the smtp_out config section. +func New(cfg config.SMTPOutConfig) *Mailer { + return &Mailer{cfg: cfg} +} + +// IsConfigured returns true when the smtp_out config is usable (host + from set). +func (m *Mailer) IsConfigured() bool { + return m.cfg.Host != "" && m.cfg.From != "" +} + +// Send sends an HTML + plaintext email to a single recipient. +func (m *Mailer) Send(to, subject, htmlBody, textBody string) error { + if !m.IsConfigured() { + return fmt.Errorf("mailer: smtp_out not configured") + } + + addr := fmt.Sprintf("%s:%d", m.cfg.Host, port(m.cfg.Port)) + + msg := buildMIME(m.cfg.From, to, subject, htmlBody, textBody) + + var auth smtp.Auth + if m.cfg.User != "" { + auth = smtp.PlainAuth("", m.cfg.User, m.cfg.Password, m.cfg.Host) + } + + if m.cfg.TLS { + return sendTLS(addr, m.cfg.Host, auth, m.cfg.From, to, msg) + } + return sendSTARTTLS(addr, auth, m.cfg.From, to, msg) +} + +func port(p int) int { + if p == 0 { + return 587 + } + return p +} + +// sendTLS connects directly via TLS (port 465). +func sendTLS(addr, host string, auth smtp.Auth, from, to string, msg []byte) error { + tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12} + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 10 * time.Second}, "tcp", addr, tlsCfg) + if err != nil { + return fmt.Errorf("mailer: tls dial: %w", err) + } + defer conn.Close() + + c, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("mailer: smtp client: %w", err) + } + defer c.Close() + + if auth != nil { + if err := c.Auth(auth); err != nil { + return fmt.Errorf("mailer: auth: %w", err) + } + } + return send(c, from, to, msg) +} + +// sendSTARTTLS connects plain and upgrades via STARTTLS (port 587). +func sendSTARTTLS(addr string, auth smtp.Auth, from, to string, msg []byte) error { + c, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("mailer: dial: %w", err) + } + defer c.Close() + + host, _, _ := net.SplitHostPort(addr) + if ok, _ := c.Extension("STARTTLS"); ok { + tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12} + if err := c.StartTLS(tlsCfg); err != nil { + return fmt.Errorf("mailer: starttls: %w", err) + } + } + if auth != nil { + if err := c.Auth(auth); err != nil { + return fmt.Errorf("mailer: auth: %w", err) + } + } + return send(c, from, to, msg) +} + +func send(c *smtp.Client, from, to string, msg []byte) error { + if err := c.Mail(from); err != nil { + return fmt.Errorf("mailer: MAIL FROM: %w", err) + } + if err := c.Rcpt(to); err != nil { + return fmt.Errorf("mailer: RCPT TO: %w", err) + } + wc, err := c.Data() + if err != nil { + return fmt.Errorf("mailer: DATA: %w", err) + } + defer wc.Close() + if _, err := wc.Write(msg); err != nil { + return fmt.Errorf("mailer: write: %w", err) + } + return nil +} + +// buildMIME constructs a multipart/alternative MIME message. +func buildMIME(from, to, subject, htmlBody, textBody string) []byte { + boundary := "----=archivmail_boundary_20260101" + var b strings.Builder + + b.WriteString("From: " + from + "\r\n") + b.WriteString("To: " + to + "\r\n") + b.WriteString("Subject: " + subject + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString(`Content-Type: multipart/alternative; boundary="` + boundary + `"` + "\r\n") + b.WriteString("\r\n") + + // Plaintext part + b.WriteString("--" + boundary + "\r\n") + b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + b.WriteString("\r\n") + b.WriteString(textBody + "\r\n") + + // HTML part + b.WriteString("--" + boundary + "\r\n") + b.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + b.WriteString("Content-Transfer-Encoding: quoted-printable\r\n") + b.WriteString("\r\n") + b.WriteString(htmlBody + "\r\n") + + b.WriteString("--" + boundary + "--\r\n") + return []byte(b.String()) +} diff --git a/internal/mailer/templates.go b/internal/mailer/templates.go new file mode 100644 index 0000000..d44409d --- /dev/null +++ b/internal/mailer/templates.go @@ -0,0 +1,65 @@ +package mailer + +import "fmt" + +// ── Email templates ─────────────────────────────────────────────────────────── +// Simple inline templates. No embed.FS needed at this stage. + +// VerifyEmailHTML returns the HTML body for an email verification message. +func VerifyEmailHTML(fqdn, token, username string) string { + link := fmt.Sprintf("https://%s/verify?token=%s", fqdn, token) + return fmt.Sprintf(` +
+Hallo %s,
+Bitte bestätige deine E-Mail-Adresse, um deinen archivmail-Account zu aktivieren.
+ +Der Link ist 24 Stunden gültig.
Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.
%s
+`, username, link, link) +} + +// VerifyEmailText returns the plain-text body for email verification. +func VerifyEmailText(fqdn, token, username string) string { + link := fmt.Sprintf("https://%s/verify?token=%s", fqdn, token) + return fmt.Sprintf("Hallo %s,\n\nbitte bestätige deine E-Mail-Adresse:\n\n%s\n\nDer Link ist 24 Stunden gültig.\n\narcivmail", username, link) +} + +// ResetPasswordHTML returns the HTML body for a password-reset message. +func ResetPasswordHTML(fqdn, token, username string) string { + link := fmt.Sprintf("https://%s/reset-password?token=%s", fqdn, token) + return fmt.Sprintf(` + +Hallo %s,
+Du hast eine Passwort-Reset-Anfrage gestellt. Klicke auf den Link, um ein neues Passwort zu setzen.
+ +Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden.
Falls du kein Passwort zurücksetzen wolltest, ignoriere diese E-Mail.
%s
+`, username, link, link) +} + +// ResetPasswordText returns the plain-text body for password reset. +func ResetPasswordText(fqdn, token, username string) string { + link := fmt.Sprintf("https://%s/reset-password?token=%s", fqdn, token) + return fmt.Sprintf("Hallo %s,\n\nPasswort zurücksetzen:\n\n%s\n\nDer Link ist 1 Stunde gültig.\n\narcivmail", username, link) +} + +// InviteHTML returns the HTML body for a tenant invitation. +func InviteHTML(fqdn, token, tenantName string) string { + link := fmt.Sprintf("https://%s/signup?invite=%s", fqdn, token) + return fmt.Sprintf(` + +Du wurdest eingeladen, dem archivmail-System beizutreten.
+ +Der Link ist 72 Stunden gültig und kann nur einmal verwendet werden.
+%s
+`, tenantName, link, link) +} + +// InviteText returns the plain-text body for a tenant invitation. +func InviteText(fqdn, token, tenantName string) string { + link := fmt.Sprintf("https://%s/signup?invite=%s", fqdn, token) + return fmt.Sprintf("Einladung zu %s:\n\n%s\n\nDer Link ist 72 Stunden gültig.\n\narcivmail", tenantName, link) +} diff --git a/internal/tokenstore/store.go b/internal/tokenstore/store.go new file mode 100644 index 0000000..05e3491 --- /dev/null +++ b/internal/tokenstore/store.go @@ -0,0 +1,154 @@ +// Package tokenstore manages single-use tokens for email verification, +// password reset, and tenant invitations (PROJ-28). +package tokenstore + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + TypeVerify = "verify" + TypeReset = "reset" + TypeInvite = "invite" +) + +// Token represents a stored auth token record. +type Token struct { + ID int64 + Type string + UserID *int64 + TenantID *int64 + ExpiresAt time.Time +} + +// Store manages auth_tokens in PostgreSQL. +type Store struct { + pool *pgxpool.Pool +} + +// New connects to PostgreSQL and initialises the token schema. +func New(pool *pgxpool.Pool) (*Store, error) { + s := &Store{pool: pool} + if err := s.initSchema(context.Background()); err != nil { + return nil, fmt.Errorf("tokenstore: init schema: %w", err) + } + return s, nil +} + +func (s *Store) initSchema(ctx context.Context) error { + _, err := s.pool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS auth_tokens ( + id BIGSERIAL PRIMARY KEY, + type VARCHAR(50) NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + tenant_id BIGINT, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens (token_hash); + CREATE INDEX IF NOT EXISTS idx_auth_tokens_expires ON auth_tokens (expires_at); + `) + return err +} + +// Create generates a new random token, stores its SHA-256 hash, and returns the plaintext token. +// userID may be nil for invite tokens (user does not exist yet). +func (s *Store) Create(ctx context.Context, tokenType string, userID *int64, tenantID *int64, ttl time.Duration) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", fmt.Errorf("tokenstore: rand: %w", err) + } + plain := base64.RawURLEncoding.EncodeToString(raw) + hash := hashToken(plain) + + _, err := s.pool.Exec(ctx, + `INSERT INTO auth_tokens (type, token_hash, user_id, tenant_id, expires_at) + VALUES ($1, $2, $3, $4, $5)`, + tokenType, hash, userID, tenantID, time.Now().Add(ttl), + ) + if err != nil { + return "", fmt.Errorf("tokenstore: create: %w", err) + } + return plain, nil +} + +// Use validates a plaintext token (type must match), marks it as used, and returns the record. +// Returns an error if the token is invalid, expired, or already used. +func (s *Store) Use(ctx context.Context, tokenType, plain string) (*Token, error) { + hash := hashToken(plain) + + var t Token + err := s.pool.QueryRow(ctx, + `SELECT id, type, user_id, tenant_id, expires_at, used_at + FROM auth_tokens + WHERE token_hash = $1 AND type = $2`, + hash, tokenType, + ).Scan(&t.ID, &t.Type, &t.UserID, &t.TenantID, &t.ExpiresAt, new(*time.Time)) + if err != nil { + return nil, fmt.Errorf("tokenstore: token not found or invalid") + } + + if time.Now().After(t.ExpiresAt) { + return nil, fmt.Errorf("tokenstore: token expired") + } + + // Mark used + tag, err := s.pool.Exec(ctx, + `UPDATE auth_tokens SET used_at = NOW() + WHERE id = $1 AND used_at IS NULL`, + t.ID, + ) + if err != nil { + return nil, fmt.Errorf("tokenstore: mark used: %w", err) + } + if tag.RowsAffected() == 0 { + return nil, fmt.Errorf("tokenstore: token already used") + } + + return &t, nil +} + +// Peek validates a token without consuming it. Used for invite token preview. +func (s *Store) Peek(ctx context.Context, tokenType, plain string) (*Token, error) { + hash := hashToken(plain) + + var t Token + var usedAt *time.Time + err := s.pool.QueryRow(ctx, + `SELECT id, type, user_id, tenant_id, expires_at, used_at + FROM auth_tokens + WHERE token_hash = $1 AND type = $2`, + hash, tokenType, + ).Scan(&t.ID, &t.Type, &t.UserID, &t.TenantID, &t.ExpiresAt, &usedAt) + if err != nil { + return nil, fmt.Errorf("tokenstore: token not found or invalid") + } + if usedAt != nil { + return nil, fmt.Errorf("tokenstore: token already used") + } + if time.Now().After(t.ExpiresAt) { + return nil, fmt.Errorf("tokenstore: token expired") + } + return &t, nil +} + +// Cleanup deletes tokens that are expired or used. +func (s *Store) Cleanup(ctx context.Context) error { + _, err := s.pool.Exec(ctx, + `DELETE FROM auth_tokens WHERE expires_at < NOW() OR used_at IS NOT NULL`, + ) + return err +} + +func hashToken(plain string) string { + sum := sha256.Sum256([]byte(plain)) + return fmt.Sprintf("%x", sum[:]) +} diff --git a/internal/userstore/userstore.go b/internal/userstore/userstore.go index e26faf2..760fc1e 100644 --- a/internal/userstore/userstore.go +++ b/internal/userstore/userstore.go @@ -144,6 +144,57 @@ func (s *Store) Create(req CreateUserRequest) (*User, error) { return s.GetByID(id) } +// CreateInactive inserts a new local user with active=false (pending email verification). +func (s *Store) CreateInactive(req CreateUserRequest) (*User, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) + if err != nil { + return nil, fmt.Errorf("userstore: bcrypt: %w", err) + } + + ctx := context.Background() + var id int64 + err = s.pool.QueryRow(ctx, + `INSERT INTO users (username, email, password_hash, role, source, active, created_at, tenant_id) + VALUES ($1, $2, $3, $4, 'local', false, NOW(), $5) + RETURNING id`, + req.Username, req.Email, string(hash), req.Role, req.TenantID, + ).Scan(&id) + if err != nil { + return nil, fmt.Errorf("userstore: create inactive: %w", err) + } + return s.GetByID(id) +} + +// Activate sets active=true for a user (called after email verification). +func (s *Store) Activate(ctx context.Context, id int64) error { + tag, err := s.pool.Exec(ctx, `UPDATE users SET active=true WHERE id=$1`, id) + if err != nil { + return fmt.Errorf("userstore: activate: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("userstore: user %d not found", id) + } + return nil +} + +// GetByEmail retrieves a user by email address. +func (s *Store) GetByEmail(ctx context.Context, email string) (*User, error) { + row := s.pool.QueryRow(ctx, + `SELECT id, username, email, role, source, active, created_at, tenant_id, totp_enabled, totp_reset_at, totp_reset_by FROM users WHERE email = $1`, email, + ) + return scanUser(row) +} + +// SetPassword updates the password hash for a user (used by password reset). +func (s *Store) SetPassword(ctx context.Context, id int64, newPassword string) error { + hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcryptCost) + if err != nil { + return fmt.Errorf("userstore: bcrypt: %w", err) + } + _, err = s.pool.Exec(ctx, `UPDATE users SET password_hash=$1 WHERE id=$2`, string(hash), id) + return err +} + // GetByID retrieves a user by their numeric ID. func (s *Store) GetByID(id int64) (*User, error) { ctx := context.Background() diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx new file mode 100644 index 0000000..4c6c036 --- /dev/null +++ b/src/app/forgot-password/page.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [done, setDone] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const res = await fetch("/api/auth/forgot-password", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }); + if (!res.ok && res.status === 503) { + const data = await res.json().catch(() => ({})); + setError((data as { error?: string }).error ?? "E-Mail-Versand nicht verfügbar."); + } else { + setDone(true); + } + } catch { + setError("Netzwerkfehler. Bitte versuche es erneut."); + } finally { + setLoading(false); + } + }; + + if (done) { + return ( +Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet. Bitte prüfe deinen Posteingang.
+ Zur Anmeldung +Wir senden dir einen Reset-Link per E-Mail.
+Dein Passwort wurde erfolgreich zurückgesetzt.
+ +Gib dein neues Passwort ein.
+Wir haben dir eine Bestätigungs-E-Mail gesendet. Bitte klicke auf den Link darin, um deinen Account zu aktivieren.
+ +Einladung zu: {tenantName}
+ )} +{inviteError}
} + ++ Zur Anmeldung +
+Bitte warten...
} + {status !== "loading" &&{message}
} + {status === "ok" && ( + + )} +