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