feat(PROJ-28): Self-Service Onboarding — Signup, Verify, Password Reset, Invites

- internal/mailer: SMTP-Out via net/smtp (TLS + STARTTLS), HTML+Text-Templates
- internal/tokenstore: auth_tokens Tabelle, SHA-256-Hash, TTL, einmalig verwendbar
- userstore: CreateInactive(), Activate(), GetByEmail(), SetPassword()
- API: POST /signup, GET /verify, POST /forgot-password, POST /reset-password
- API: POST /admin/invite (domain_admin+), GET /auth/invite?token (check)
- Login-Seite: Links zu "Passwort vergessen" und "Registrieren"
- Frontend: /signup, /verify, /forgot-password, /reset-password Seiten
- server.fqdn nicht konfiguriert → Startup-Warnung, Self-Service deaktiviert
- LDAP-Nutzer: Passwort-Reset abgewiesen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-03-31 21:54:11 +02:00
parent 7930b85cde
commit 4583262ea4
13 changed files with 1232 additions and 0 deletions
+24
View File
@@ -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)
+106
View File
@@ -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,
})
}
+234
View File
@@ -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."})
}
+28
View File
@@ -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))
+152
View File
@@ -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())
}
+65
View File
@@ -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(`<!DOCTYPE html>
<html><body style="font-family:sans-serif;max-width:600px;margin:40px auto;color:#333">
<h2>E-Mail-Adresse bestätigen</h2>
<p>Hallo %s,</p>
<p>Bitte bestätige deine E-Mail-Adresse, um deinen archivmail-Account zu aktivieren.</p>
<p><a href="%s" style="background:#2563eb;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">E-Mail bestätigen</a></p>
<p style="color:#666;font-size:13px">Der Link ist 24 Stunden gültig.<br>Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren.</p>
<p style="color:#999;font-size:12px">%s</p>
</body></html>`, 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(`<!DOCTYPE html>
<html><body style="font-family:sans-serif;max-width:600px;margin:40px auto;color:#333">
<h2>Passwort zurücksetzen</h2>
<p>Hallo %s,</p>
<p>Du hast eine Passwort-Reset-Anfrage gestellt. Klicke auf den Link, um ein neues Passwort zu setzen.</p>
<p><a href="%s" style="background:#2563eb;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">Passwort zurücksetzen</a></p>
<p style="color:#666;font-size:13px">Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden.<br>Falls du kein Passwort zurücksetzen wolltest, ignoriere diese E-Mail.</p>
<p style="color:#999;font-size:12px">%s</p>
</body></html>`, 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(`<!DOCTYPE html>
<html><body style="font-family:sans-serif;max-width:600px;margin:40px auto;color:#333">
<h2>Einladung zu %s</h2>
<p>Du wurdest eingeladen, dem archivmail-System beizutreten.</p>
<p><a href="%s" style="background:#2563eb;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block">Einladung annehmen</a></p>
<p style="color:#666;font-size:13px">Der Link ist 72 Stunden gültig und kann nur einmal verwendet werden.</p>
<p style="color:#999;font-size:12px">%s</p>
</body></html>`, 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)
}
+154
View File
@@ -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[:])
}
+51
View File
@@ -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()
+88
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle>E-Mail gesendet</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-sm text-muted-foreground">
<p>Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet. Bitte prüfe deinen Posteingang.</p>
<a href="/" className="text-sm underline hover:text-foreground">Zur Anmeldung</a>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Passwort vergessen</CardTitle>
<p className="text-sm text-muted-foreground">Wir senden dir einen Reset-Link per E-Mail.</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input
id="email"
type="email"
placeholder="deine@email.de"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Senden..." : "Reset-Link senden"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
<a href="/" className="underline hover:text-foreground">Zurück zur Anmeldung</a>
</p>
</CardContent>
</Card>
</div>
);
}
+8
View File
@@ -86,6 +86,14 @@ export default function LoginPage() {
{loading ? "Anmelden..." : "Anmelden"}
</Button>
</form>
<div className="mt-4 flex flex-col items-center gap-2 text-sm text-muted-foreground">
<a href="/forgot-password" className="hover:text-foreground underline">
Passwort vergessen?
</a>
<a href="/signup" className="hover:text-foreground underline">
Noch kein Account? Registrieren
</a>
</div>
</CardContent>
</Card>
</div>
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
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";
function ResetForm() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("Passwörter stimmen nicht überein.");
return;
}
if (password.length < 8) {
setError("Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
setLoading(true);
try {
const res = await fetch("/api/auth/reset-password", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? "Fehler beim Zurücksetzen.");
} else {
setDone(true);
}
} catch {
setError("Netzwerkfehler. Bitte versuche es erneut.");
} finally {
setLoading(false);
}
};
if (!token) {
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center"><CardTitle>Fehler</CardTitle></CardHeader>
<CardContent className="text-center text-sm text-destructive">
Kein Reset-Token angegeben.
<div className="mt-4"><a href="/forgot-password" className="underline hover:text-foreground">Neuen Link anfordern</a></div>
</CardContent>
</Card>
);
}
if (done) {
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center"><CardTitle>Passwort geändert</CardTitle></CardHeader>
<CardContent className="space-y-4 text-center text-sm text-muted-foreground">
<p>Dein Passwort wurde erfolgreich zurückgesetzt.</p>
<Button className="w-full" onClick={() => window.location.href = "/"}>Zur Anmeldung</Button>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Neues Passwort</CardTitle>
<p className="text-sm text-muted-foreground">Gib dein neues Passwort ein.</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="password">Neues Passwort</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="new-password"
minLength={8}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm">Passwort bestätigen</Label>
<Input
id="confirm"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
autoComplete="new-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Speichern..." : "Passwort ändern"}
</Button>
</form>
</CardContent>
</Card>
);
}
export default function ResetPasswordPage() {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Suspense>
<ResetForm />
</Suspense>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
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";
async function checkInvite(token: string): Promise<string | null> {
const res = await fetch(`/api/auth/invite?token=${encodeURIComponent(token)}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json();
return data.tenant_name ?? null;
}
async function signup(body: {
username: string;
email: string;
password: string;
invite?: string;
}): Promise<void> {
const res = await fetch("/api/auth/signup", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error((err as { error?: string }).error ?? "Registrierung fehlgeschlagen");
}
}
function SignupForm() {
const router = useRouter();
const params = useSearchParams();
const invite = params.get("invite") ?? "";
const [tenantName, setTenantName] = useState<string | null>(null);
const [inviteError, setInviteError] = useState("");
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!invite) return;
checkInvite(invite).then((name) => {
if (name === null) setInviteError("Ungültiger oder abgelaufener Einladungslink.");
else setTenantName(name);
});
}, [invite]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await signup({ username, email, password, invite: invite || undefined });
setDone(true);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Fehler");
} finally {
setLoading(false);
}
};
if (done) {
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle>Fast geschafft!</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center text-sm text-muted-foreground">
<p>Wir haben dir eine Bestätigungs-E-Mail gesendet. Bitte klicke auf den Link darin, um deinen Account zu aktivieren.</p>
<Button variant="outline" className="w-full" onClick={() => router.push("/")}>
Zur Anmeldung
</Button>
</CardContent>
</Card>
);
}
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Registrieren</CardTitle>
{tenantName && (
<p className="text-sm text-muted-foreground">Einladung zu: <strong>{tenantName}</strong></p>
)}
</CardHeader>
<CardContent>
{inviteError && <p className="text-sm text-destructive mb-4">{inviteError}</p>}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Benutzername</Label>
<Input id="username" value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="username" />
</div>
<div className="space-y-2">
<Label htmlFor="email">E-Mail-Adresse</Label>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="new-password" minLength={8} />
<p className="text-xs text-muted-foreground">Mindestens 8 Zeichen</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Registrierung..." : "Account erstellen"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
<a href="/" className="underline hover:text-foreground">Zur Anmeldung</a>
</p>
</CardContent>
</Card>
);
}
export default function SignupPage() {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Suspense>
<SignupForm />
</Suspense>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useState, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
function VerifyContent() {
const params = useSearchParams();
const token = params.get("token") ?? "";
const [status, setStatus] = useState<"loading" | "ok" | "error">("loading");
const [message, setMessage] = useState("");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Kein Token angegeben.");
return;
}
fetch(`/api/auth/verify?token=${encodeURIComponent(token)}`, { credentials: "include" })
.then(async (res) => {
const data = await res.json().catch(() => ({}));
if (res.ok) {
setStatus("ok");
setMessage((data as { message?: string }).message ?? "E-Mail bestätigt.");
} else {
setStatus("error");
setMessage((data as { error?: string }).error ?? "Ungültiger oder abgelaufener Link.");
}
})
.catch(() => {
setStatus("error");
setMessage("Netzwerkfehler. Bitte versuche es erneut.");
});
}, [token]);
return (
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle>{status === "ok" ? "E-Mail bestätigt" : status === "error" ? "Fehler" : "Bestätigen..."}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
{status === "loading" && <p className="text-sm text-muted-foreground">Bitte warten...</p>}
{status !== "loading" && <p className="text-sm">{message}</p>}
{status === "ok" && (
<Button className="w-full" onClick={() => window.location.href = "/"}>
Zur Anmeldung
</Button>
)}
</CardContent>
</Card>
);
}
export default function VerifyPage() {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Suspense>
<VerifyContent />
</Suspense>
</div>
);
}