2bab61209c
Go-Modul in go.mod und allen 45 Go-Dateien umbenannt.
245 lines
7.0 KiB
Go
245 lines
7.0 KiB
Go
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"time"
|
||
|
||
"archivmail/internal/audit"
|
||
"archivmail/internal/mailer"
|
||
"archivmail/internal/tokenstore"
|
||
"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
|
||
}
|
||
|
||
const signupMsg = "Wenn die E-Mail-Adresse verfügbar ist, wurde eine Bestätigungs-E-Mail gesendet."
|
||
|
||
u, err := s.users.CreateInactive(req)
|
||
if err != nil {
|
||
// SEC: Send "already registered" email to prevent account enumeration via
|
||
// email delivery side-channel. Every signup attempt produces an outgoing email.
|
||
if s.mailer.IsConfigured() {
|
||
go func() {
|
||
html := mailer.AlreadyRegisteredHTML(s.fqdn)
|
||
txt := mailer.AlreadyRegisteredText(s.fqdn)
|
||
_ = s.mailer.Send(body.Email, "archivmail – Registrierungsversuch", html, txt)
|
||
}()
|
||
}
|
||
writeJSON(w, http.StatusOK, map[string]string{"message": signupMsg})
|
||
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."})
|
||
}
|