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
+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."})
}