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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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."})
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user