feat(auth): confirm the e-mail address of new accounts by link
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m21s
- Setting / REGISTRATION_VERIFY_EMAIL; only with SMTP and BASE_URL; invites skip it - Hashed one-time tokens (48 h), resend with cooldown, unconfirmed accounts purged - Approval modes notify admins only after the address is confirmed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1406650b7c
commit
ab97638361
25 files changed
+621
-56
No files matched your search
@@ -76,6 +76,9 @@ func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.notifyEmailHook != nil { // tests
|
||||
return s.notifyEmailHook(toEmail, subject, bodyText, html)
|
||||
}
|
||||
if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
|
||||
step := ""
|
||||
if se, ok := err.(*smtpStepError); ok {
|
||||
|
||||
@@ -270,6 +270,9 @@ func (s *Server) startCompaction() {
|
||||
|
||||
func (s *Server) compactOnce() {
|
||||
s.leases.Sweep()
|
||||
if n := s.store.PurgeUnverifiedAccounts(time.Now()); n > 0 {
|
||||
slog.Info("Unbestätigte Registrierungen gelöscht", "count", n)
|
||||
}
|
||||
s.compactMedia(time.Now())
|
||||
_ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour))
|
||||
st, err := s.store.CompactDocs(time.Now())
|
||||
|
||||
@@ -95,8 +95,10 @@ func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
pending := s.store.PendingRegistrationIDs()
|
||||
unverified := s.store.UnverifiedEmailIDs()
|
||||
for i := range users {
|
||||
users[i].PendingApproval = pending[users[i].ID]
|
||||
users[i].EmailUnverified = unverified[users[i].ID]
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
@@ -197,7 +199,9 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
s.wsHub.DisconnectUser(targetUserID)
|
||||
}
|
||||
if req.IsActive != nil && *req.IsActive {
|
||||
s.store.ClearPendingRegistration(targetUserID) // activating by hand counts as approval
|
||||
// Activating by hand counts as approval and vouches for the address
|
||||
s.store.ClearPendingRegistration(targetUserID)
|
||||
s.store.ClearEmailVerification(targetUserID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
@@ -332,6 +336,11 @@ func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.Sys
|
||||
out.RegistrationDomains = strings.Join(storage.ParseEmailDomains(s.cfg.RegistrationDomains), ", ")
|
||||
out.RegistrationConfiguredViaEnv = true
|
||||
}
|
||||
if s.cfg.RegistrationVerifyEmail != "" {
|
||||
out.RegistrationVerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
|
||||
out.RegistrationVerifyViaEnv = true
|
||||
}
|
||||
out.EmailLinksAvailable = s.emailLinksAvailable()
|
||||
return &out
|
||||
}
|
||||
|
||||
@@ -358,6 +367,11 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.cfg.RegistrationVerifyEmail != "" {
|
||||
if cur, err := s.store.GetSystemSettings(); err == nil {
|
||||
settings.RegistrationVerifyEmail = cur.RegistrationVerifyEmail
|
||||
}
|
||||
}
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"allowRegistration": policy.Mode != storage.RegistrationClosed,
|
||||
"registrationMode": policy.Mode,
|
||||
"registrationDomains": policy.Domains,
|
||||
"emailVerification": policy.VerifyEmail,
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
@@ -199,6 +200,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
role := "member"
|
||||
var validInvite *domain.UserInvite
|
||||
pending := false // registered without invite while approval is needed
|
||||
verify := false // registered without invite while the address must be confirmed
|
||||
|
||||
if strings.TrimSpace(req.InviteCode) != "" {
|
||||
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
|
||||
@@ -215,8 +217,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
} else if count == 0 {
|
||||
role = "admin"
|
||||
} else {
|
||||
policy := s.registrationPolicy()
|
||||
var err error
|
||||
pending, err = s.registrationPolicy().registrationOutcome(req.Email)
|
||||
pending, err = policy.registrationOutcome(req.Email)
|
||||
verify = policy.VerifyEmail
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
@@ -261,22 +265,35 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Waiting for approval: the account exists but stays locked, no session yet
|
||||
if pending {
|
||||
// Waiting for the e-mail confirmation and/or approval: the account exists but stays locked, no session
|
||||
if pending || verify {
|
||||
inactive := false
|
||||
if _, err := s.store.UpdateUser(user.ID, nil, nil, nil, &inactive, nil, nil); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
if err := s.store.AddPendingRegistration(user.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
|
||||
return
|
||||
if pending {
|
||||
if err := s.store.AddPendingRegistration(user.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Konto konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
}
|
||||
slog.Info("Registrierung wartet auf Freigabe", "user_id", user.ID, "email", user.Email)
|
||||
go s.notifyAdminsOfRegistration(user)
|
||||
message := "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst."
|
||||
if verify {
|
||||
// Admins hear about it only once the address is confirmed
|
||||
go s.sendVerificationMail(user)
|
||||
message = "Fast geschafft: Wir haben dir eine E-Mail an " + user.Email + " geschickt. Bestätige deine Adresse über den Link darin."
|
||||
if pending {
|
||||
message += " Danach schaltet ein Administrator dein Konto frei."
|
||||
}
|
||||
} else {
|
||||
go s.notifyAdminsOfRegistration(user)
|
||||
}
|
||||
slog.Info("Registrierung wartet", "user_id", user.ID, "email", user.Email, "approval", pending, "verify", verify)
|
||||
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
||||
"pending": true,
|
||||
"message": "Dein Konto wurde angelegt und wartet auf die Freigabe durch einen Administrator. Du bekommst eine E-Mail, sobald du dich anmelden kannst.",
|
||||
"verify": verify,
|
||||
"message": message,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -343,6 +360,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
s.loginGuard.recordSuccess(account, ip)
|
||||
if !user.IsActive && s.store.IsEmailUnverified(user.ID) {
|
||||
writeError(w, http.StatusForbidden, "Bitte bestätige zuerst deine E-Mail-Adresse über den Link, den wir dir geschickt haben")
|
||||
return
|
||||
}
|
||||
if !user.IsActive && s.store.IsPendingRegistration(user.ID) {
|
||||
writeError(w, http.StatusForbidden, "Dein Konto wartet noch auf die Freigabe durch einen Administrator")
|
||||
return
|
||||
|
||||
@@ -22,6 +22,9 @@ type registrationPolicy struct {
|
||||
Mode string
|
||||
Domains []string
|
||||
ViaEnv bool
|
||||
// VerifyEmail: self-registrations confirm their address by link first (only when mails with links
|
||||
// can go out, see emailLinksAvailable)
|
||||
VerifyEmail bool
|
||||
}
|
||||
|
||||
// registrationPolicy: the environment (REGISTRATION_MODE / REGISTRATION_DOMAINS) wins over the admin
|
||||
@@ -31,7 +34,12 @@ func (s *Server) registrationPolicy() registrationPolicy {
|
||||
if sys, _ := s.store.GetSystemSettings(); sys != nil {
|
||||
p.Mode = sys.RegistrationMode
|
||||
p.Domains = storage.ParseEmailDomains(sys.RegistrationDomains)
|
||||
p.VerifyEmail = sys.RegistrationVerifyEmail
|
||||
}
|
||||
if s.cfg.RegistrationVerifyEmail != "" {
|
||||
p.VerifyEmail = s.cfg.RegistrationVerifyEmail == "true"
|
||||
}
|
||||
p.VerifyEmail = p.VerifyEmail && s.emailLinksAvailable()
|
||||
if s.cfg.RegistrationMode != "" {
|
||||
p.Mode, p.ViaEnv = s.cfg.RegistrationMode, true
|
||||
}
|
||||
@@ -72,6 +80,100 @@ func domainList(domains []string) string {
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
|
||||
// emailLinksAvailable: mails with links to people who are not logged in can go out – a mail server
|
||||
// and BASE_URL (links to strangers are never built from the request's Host header).
|
||||
func (s *Server) emailLinksAvailable() bool {
|
||||
return s.passwordResetAvailable()
|
||||
}
|
||||
|
||||
// sendVerificationMail mails the confirmation link of a new account.
|
||||
func (s *Server) sendVerificationMail(user *domain.User) {
|
||||
raw, err := s.store.CreateEmailVerification(user.ID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, storage.ErrResetTooSoon) {
|
||||
slog.Warn("Bestätigungslink konnte nicht erzeugt werden", "user_id", user.ID, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
link := strings.TrimRight(s.cfg.BaseURL, "/") + "/?verifyToken=" + url.QueryEscape(raw)
|
||||
err = s.sendMailContent(user.Email, "MindSift: Bitte bestätige deine E-Mail-Adresse", mailContent{
|
||||
Preheader: "Ein Klick, dann ist dein Konto bereit",
|
||||
Greeting: mailGreeting(time.Now(), user.DisplayName),
|
||||
Intro: "Du hast dich bei MindSift registriert. Bitte bestätige noch deine E-Mail-Adresse.",
|
||||
Button: &mailButton{Label: "E-Mail-Adresse bestätigen", URL: link},
|
||||
Note: "Der Link gilt 48 Stunden. Hast du dich nicht registriert, ignoriere diese E-Mail – das Konto wird dann automatisch gelöscht.\n" +
|
||||
"Falls der Knopf nicht geht: " + link,
|
||||
Footer: mailFooterAccount,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("Bestätigungs-E-Mail nicht verschickt", "user_id", user.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVerifyEmail: POST /auth/verify-email {"token"} – the link from the mail. Without approval the
|
||||
// account is active now, otherwise it goes on to the admins.
|
||||
func (s *Server) handleVerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Token) == "" {
|
||||
writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error())
|
||||
return
|
||||
}
|
||||
userID, err := s.store.ConfirmEmailVerification(strings.TrimSpace(req.Token))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
if err != nil || user == nil {
|
||||
writeError(w, http.StatusBadRequest, storage.ErrVerificationInvalid.Error())
|
||||
return
|
||||
}
|
||||
if s.store.IsPendingRegistration(userID) {
|
||||
go s.notifyAdminsOfRegistration(user)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"verified": true, "pending": true,
|
||||
"message": "Deine E-Mail-Adresse ist bestätigt. Ein Administrator schaltet dein Konto noch frei – du bekommst dann eine E-Mail.",
|
||||
})
|
||||
return
|
||||
}
|
||||
active := true
|
||||
if _, err := s.store.UpdateUser(userID, nil, nil, nil, &active, nil, nil); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Konto konnte nicht freigeschaltet werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"verified": true, "pending": false,
|
||||
"message": "Deine E-Mail-Adresse ist bestätigt. Du kannst dich jetzt anmelden.",
|
||||
})
|
||||
}
|
||||
|
||||
// handleResendVerification: POST /auth/resend-verification {"email"} – always the same answer, the
|
||||
// mail goes out in the background (at most every 2 minutes per account).
|
||||
func (s *Server) handleResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
if wait := s.loginGuard.throttle("forgot", clientIPFrom(r), limitForgotPerIP); wait > 0 {
|
||||
writeThrottled(w, wait)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
if s.emailLinksAvailable() {
|
||||
go func() {
|
||||
if user, err := s.store.GetUserByEmail(email); err == nil && user != nil && s.store.IsEmailUnverified(user.ID) {
|
||||
s.sendVerificationMail(user)
|
||||
}
|
||||
}()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
// appLink is the address of this instance for links in mails: BASE_URL, otherwise the address the
|
||||
// admin is using right now (only for mails an admin triggers, never for anonymous requests).
|
||||
func (s *Server) appLink(r *http.Request) string {
|
||||
@@ -137,6 +239,7 @@ func (s *Server) handleAdminApproveUser(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
user.IsActive = true
|
||||
s.store.ClearEmailVerification(userID) // the admin vouches for the account
|
||||
link := s.appLink(r)
|
||||
go func() {
|
||||
_ = s.sendMailContent(user.Email, "MindSift: Dein Konto ist freigeschaltet", mailContent{
|
||||
|
||||
@@ -3,8 +3,11 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
@@ -155,3 +158,124 @@ func TestRegistrationFixedByEnvironment(t *testing.T) {
|
||||
t.Fatalf("the locked value must not be overwritten in the database")
|
||||
}
|
||||
}
|
||||
|
||||
// verificationServer: mails can go out (SMTP + BASE_URL); returns the captured mails
|
||||
func verificationServer(t *testing.T) (*Server, *storage.Store, string, func() []sentNote) {
|
||||
t.Helper()
|
||||
srv, store, adminToken := setupTestServer(t)
|
||||
srv.cfg.BaseURL, srv.cfg.BaseURLSet = "https://mindsift.example", true
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.SMTPHost, sys.RegistrationVerifyEmail = "smtp.example.com", true
|
||||
if err := store.UpdateSystemSettings(sys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mu sync.Mutex
|
||||
var sent []sentNote
|
||||
srv.notifyEmailHook = func(to, subject, text, html string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
sent = append(sent, sentNote{channel: "email", to: to, subject: subject, text: text, html: html})
|
||||
return nil
|
||||
}
|
||||
mails := func() []sentNote {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]sentNote(nil), sent...)
|
||||
}
|
||||
return srv, store, adminToken, mails
|
||||
}
|
||||
|
||||
// waitMail waits for a mail to `to` (mails go out in the background)
|
||||
func waitMail(t *testing.T, mails func() []sentNote, to string) sentNote {
|
||||
t.Helper()
|
||||
for i := 0; i < 100; i++ {
|
||||
for _, m := range mails() {
|
||||
if m.to == to {
|
||||
return m
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("no mail to %s, got %+v", to, mails())
|
||||
return sentNote{}
|
||||
}
|
||||
|
||||
func TestEmailVerification(t *testing.T) {
|
||||
srv, store, _, mails := verificationServer(t)
|
||||
router := srv.Router()
|
||||
setRegistration(t, store, storage.RegistrationOpen, "")
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.RegistrationVerifyEmail = true
|
||||
_ = store.UpdateSystemSettings(sys)
|
||||
|
||||
code, out := register(srv, "neu@example.com", "")
|
||||
if code != http.StatusAccepted || out["verify"] != true || out["token"] != nil {
|
||||
t.Fatalf("register: want 202 with verify and no session, got %d %v", code, out)
|
||||
}
|
||||
mail := waitMail(t, mails, "neu@example.com")
|
||||
m := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)
|
||||
if m == nil || !strings.HasPrefix(mail.text[strings.Index(mail.text, "https://"):], "https://mindsift.example/") {
|
||||
t.Fatalf("mail must carry a link built from BASE_URL: %s", mail.text)
|
||||
}
|
||||
login := func() int {
|
||||
return sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"neu@example.com","password":"password123"}`, "").Code
|
||||
}
|
||||
if c := login(); c != http.StatusForbidden {
|
||||
t.Fatalf("login before confirming: want 403, got %d", c)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+strings.Repeat("0", 64)+`"}`, ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("wrong token: want 400, got %d", rec.Code)
|
||||
}
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, "")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":false`) {
|
||||
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if c := login(); c != http.StatusOK {
|
||||
t.Fatalf("login after confirming: want 200, got %d", c)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+m[1]+`"}`, ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("a link works only once, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Invites need no confirmation
|
||||
inv, _ := store.CreateUserInvite("", "", "member", "", 7, 1)
|
||||
if code, out := register(srv, "eingeladen@example.com", inv.Code); code != http.StatusCreated || out["token"] == nil {
|
||||
t.Fatalf("invite: want 201 with session, got %d %v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailVerificationBeforeApproval(t *testing.T) {
|
||||
srv, store, _, mails := verificationServer(t)
|
||||
router := srv.Router()
|
||||
setRegistration(t, store, storage.RegistrationApproval, "")
|
||||
sys, _ := store.GetSystemSettings()
|
||||
sys.RegistrationVerifyEmail = true
|
||||
_ = store.UpdateSystemSettings(sys)
|
||||
admin, err := store.GetFirstUser()
|
||||
if err != nil || admin == nil || admin.Role != "admin" {
|
||||
t.Fatalf("test server needs its admin, got %+v %v", admin, err)
|
||||
}
|
||||
adminEmail := admin.Email
|
||||
|
||||
if code, _ := register(srv, "wartet@example.com", ""); code != http.StatusAccepted {
|
||||
t.Fatalf("register: want 202, got %d", code)
|
||||
}
|
||||
mail := waitMail(t, mails, "wartet@example.com")
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
for _, m := range mails() {
|
||||
if m.to != "wartet@example.com" {
|
||||
t.Fatalf("admins must not hear about it before the address is confirmed, got mail to %s", m.to)
|
||||
}
|
||||
}
|
||||
token := regexp.MustCompile(`verifyToken=([0-9a-f]{64})`).FindStringSubmatch(mail.text)[1]
|
||||
rec := sessionCall(router, "POST", "/api/v1/auth/verify-email", "", `{"token":"`+token+`"}`, "")
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"pending":true`) {
|
||||
t.Fatalf("verify: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if m := waitMail(t, mails, adminEmail); !strings.Contains(m.subject, "Freigabe") {
|
||||
t.Fatalf("admin mail after confirming expected, got %q", m.subject)
|
||||
}
|
||||
if c := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"wartet@example.com","password":"password123"}`, "").Code; c != http.StatusForbidden {
|
||||
t.Fatalf("still waiting for approval: want 403, got %d", c)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/auth/status", s.handleAuthStatus)
|
||||
r.Get("/api/v1/legal/{type}", s.handleLegalText)
|
||||
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
|
||||
r.Post("/api/v1/auth/verify-email", s.handleVerifyEmail)
|
||||
r.Post("/api/v1/auth/resend-verification", s.handleResendVerification)
|
||||
r.Post("/api/v1/auth/register", s.handleRegister)
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||
|
||||
+40
-26
@@ -26,9 +26,11 @@ type Config struct {
|
||||
// ALLOW_REGISTRATION=false (the template default) leaves the choice to the admin settings.
|
||||
RegistrationMode string
|
||||
RegistrationDomains string
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
// RegistrationVerifyEmail: REGISTRATION_VERIFY_EMAIL ("true"/"false", "" = admin settings decide)
|
||||
RegistrationVerifyEmail string
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
// DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never)
|
||||
DemoResetMinutes int
|
||||
// TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs)
|
||||
@@ -233,29 +235,30 @@ func Load() *Config {
|
||||
smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true"
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DataDir: dataDir,
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: jwtSecret,
|
||||
BaseURL: baseURL,
|
||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||
AllowRegistration: allowReg,
|
||||
RegistrationMode: registrationMode(allowReg),
|
||||
RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")),
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
DemoResetMinutes: demoResetMinutes(),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
|
||||
SMTPTLS: smtpTLS,
|
||||
SMTPSkipVerify: smtpSkipVerify,
|
||||
Port: port,
|
||||
DataDir: dataDir,
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: jwtSecret,
|
||||
BaseURL: baseURL,
|
||||
BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "",
|
||||
AllowRegistration: allowReg,
|
||||
RegistrationMode: registrationMode(allowReg),
|
||||
RegistrationDomains: strings.TrimSpace(os.Getenv("REGISTRATION_DOMAINS")),
|
||||
RegistrationVerifyEmail: boolEnvOrEmpty("REGISTRATION_VERIFY_EMAIL"),
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
DemoResetMinutes: demoResetMinutes(),
|
||||
TrustedProxies: getEnv("TRUSTED_PROXIES", "private"),
|
||||
DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false",
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPFromName: getEnv("SMTP_FROM_NAME", "MindSift"),
|
||||
SMTPTLS: smtpTLS,
|
||||
SMTPSkipVerify: smtpSkipVerify,
|
||||
Speech: SpeechConfig{
|
||||
URL: strings.TrimRight(getEnv("STT_URL", ""), "/"),
|
||||
Model: getEnv("STT_MODEL", "deepdml/faster-whisper-large-v3-turbo-ct2"),
|
||||
@@ -349,3 +352,14 @@ func registrationMode(allowReg bool) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// boolEnvOrEmpty: "true" or "false" when the variable is set to a boolean, "" when unset or unknown.
|
||||
func boolEnvOrEmpty(key string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
|
||||
case "true", "1", "yes", "on":
|
||||
return "true"
|
||||
case "false", "0", "no", "off":
|
||||
return "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -137,6 +137,8 @@ type User struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
// Registered while registration needs approval and not approved yet (admin user list only)
|
||||
PendingApproval bool `json:"pendingApproval,omitempty"`
|
||||
// Registered and the e-mail address is not confirmed yet (admin user list only)
|
||||
EmailUnverified bool `json:"emailUnverified,omitempty"`
|
||||
// Only filled for the user's own profile (GET /auth/me, PATCH /auth/settings)
|
||||
NotificationSettings *NotificationSettings `json:"notificationSettings,omitempty"`
|
||||
}
|
||||
@@ -363,11 +365,17 @@ type SystemSettings struct {
|
||||
// E-mail domains for the domain modes, comma separated ("firma.de, familie.de")
|
||||
RegistrationDomains string `json:"registrationDomains"`
|
||||
// Set by REGISTRATION_MODE / REGISTRATION_DOMAINS (or ALLOW_REGISTRATION=true): not editable here
|
||||
RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
RegistrationConfiguredViaEnv bool `json:"registrationConfiguredViaEnv,omitempty"`
|
||||
// Self-registrations confirm their e-mail address by link first (needs SMTP and BASE_URL)
|
||||
RegistrationVerifyEmail bool `json:"registrationVerifyEmail"`
|
||||
// Set by REGISTRATION_VERIFY_EMAIL: not editable here
|
||||
RegistrationVerifyViaEnv bool `json:"registrationVerifyConfiguredViaEnv,omitempty"`
|
||||
// Mails with links to unknown people can be sent (SMTP and BASE_URL configured)
|
||||
EmailLinksAvailable bool `json:"emailLinksAvailable,omitempty"`
|
||||
InstanceName string `json:"instanceName"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUser string `json:"smtpUser"`
|
||||
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
|
||||
// writing it empty keeps the stored password
|
||||
SMTPPass string `json:"smtpPass"`
|
||||
|
||||
@@ -31,6 +31,7 @@ var migrations = []migration{
|
||||
{8, "Journal", (*Store).migrateJournal},
|
||||
{9, "Web-Push", (*Store).migratePush},
|
||||
{10, "Registrierung und Einladungslinks", (*Store).migrateRegistration},
|
||||
{11, "E-Mail-Bestätigung", (*Store).migrateEmailVerification},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -125,3 +128,124 @@ func (s *Store) ApprovePendingRegistration(userID string) (bool, error) {
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// --- E-mail verification ---
|
||||
// Accounts registered without invite while verification is on stay locked until the link in the
|
||||
// mail is opened. Only the SHA-256 of the token is stored (like password reset links); unconfirmed
|
||||
// accounts are deleted after EmailVerificationTTL so the address is free again.
|
||||
|
||||
const (
|
||||
EmailVerificationTTL = 48 * time.Hour
|
||||
emailVerificationCooldown = 2 * time.Minute // no second mail for the same account within this time
|
||||
)
|
||||
|
||||
var ErrVerificationInvalid = errors.New("Der Bestätigungslink ist ungültig oder abgelaufen. Bitte registriere dich erneut.")
|
||||
|
||||
func (s *Store) migrateEmailVerification() error {
|
||||
return s.execInTx(
|
||||
stmt(`CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token_hash)`),
|
||||
)
|
||||
}
|
||||
|
||||
// CreateEmailVerification returns a new raw token for the account (the previous one stops working),
|
||||
// or ErrResetTooSoon while the last mail is younger than the cooldown. The expiry of the account stays
|
||||
// counted from the registration.
|
||||
func (s *Store) CreateEmailVerification(userID string) (string, error) {
|
||||
now := time.Now().UTC()
|
||||
var created, expires string
|
||||
err := s.db.QueryRow(`SELECT created_at, expires_at FROM email_verifications WHERE user_id = ?`, userID).Scan(&created, &expires)
|
||||
if err == nil {
|
||||
if t, perr := time.Parse(time.RFC3339, created); perr == nil && now.Sub(t) < emailVerificationCooldown {
|
||||
return "", ErrResetTooSoon
|
||||
}
|
||||
} else {
|
||||
expires = now.Add(EmailVerificationTTL).Format(time.RFC3339)
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := hex.EncodeToString(buf)
|
||||
_, err = s.db.Exec(`INSERT INTO email_verifications (user_id, token_hash, created_at, expires_at) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET token_hash = excluded.token_hash, created_at = excluded.created_at`,
|
||||
userID, hashResetToken(raw), now.Format(time.RFC3339), expires)
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// ConfirmEmailVerification checks the token, ends the verification and returns the account.
|
||||
func (s *Store) ConfirmEmailVerification(raw string) (string, error) {
|
||||
var userID, expires string
|
||||
if err := s.db.QueryRow(`SELECT user_id, expires_at FROM email_verifications WHERE token_hash = ?`, hashResetToken(raw)).
|
||||
Scan(&userID, &expires); err != nil {
|
||||
return "", ErrVerificationInvalid
|
||||
}
|
||||
if exp, err := time.Parse(time.RFC3339, expires); err != nil || time.Now().UTC().After(exp) {
|
||||
return "", ErrVerificationInvalid
|
||||
}
|
||||
res, err := s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ? AND token_hash = ?`, userID, hashResetToken(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return "", ErrVerificationInvalid // used in parallel
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func (s *Store) IsEmailUnverified(userID string) bool {
|
||||
var n int
|
||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM email_verifications WHERE user_id = ?`, userID).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func (s *Store) ClearEmailVerification(userID string) {
|
||||
_, _ = s.db.Exec(`DELETE FROM email_verifications WHERE user_id = ?`, userID)
|
||||
}
|
||||
|
||||
// UnverifiedEmailIDs: accounts whose address is not confirmed yet.
|
||||
func (s *Store) UnverifiedEmailIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
rows, err := s.db.Query(`SELECT user_id FROM email_verifications`)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PurgeUnverifiedAccounts deletes accounts whose confirmation link expired unused; returns how many.
|
||||
func (s *Store) PurgeUnverifiedAccounts(now time.Time) int {
|
||||
rows, err := s.db.Query(`SELECT user_id FROM email_verifications WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if rows.Scan(&id) == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
_ = rows.Close()
|
||||
n := 0
|
||||
for _, id := range ids {
|
||||
if err := s.DeleteUser(id); err == nil {
|
||||
n++
|
||||
}
|
||||
s.ClearEmailVerification(id)
|
||||
s.ClearPendingRegistration(id)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Unconfirmed accounts disappear once their link expired, so the address is free again
|
||||
func TestUnverifiedAccountsArePurged(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "verify.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
u, _ := store.CreateUser("neu@example.com", "hash", "Neu", "member")
|
||||
keep, _ := store.CreateUser("bleibt@example.com", "hash", "Bleibt", "member")
|
||||
if _, err := store.CreateEmailVerification(u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := store.CreateEmailVerification(u.ID); err != ErrResetTooSoon {
|
||||
t.Fatalf("second mail within the cooldown must be refused, got %v", err)
|
||||
}
|
||||
if n := store.PurgeUnverifiedAccounts(time.Now()); n != 0 {
|
||||
t.Fatalf("nothing expired yet, purged %d", n)
|
||||
}
|
||||
if n := store.PurgeUnverifiedAccounts(time.Now().Add(EmailVerificationTTL + time.Hour)); n != 1 {
|
||||
t.Fatalf("want 1 purged, got %d", n)
|
||||
}
|
||||
if got, _ := store.GetUserByID(u.ID); got != nil {
|
||||
t.Fatal("unconfirmed account must be gone")
|
||||
}
|
||||
if got, _ := store.GetUserByID(keep.ID); got == nil {
|
||||
t.Fatal("other accounts stay")
|
||||
}
|
||||
}
|
||||
@@ -142,6 +142,8 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) {
|
||||
settings.RegistrationMode = NormalizeRegistrationMode(v)
|
||||
case "registration_domains":
|
||||
settings.RegistrationDomains = v
|
||||
case "registration_verify_email":
|
||||
settings.RegistrationVerifyEmail = v == "true"
|
||||
case "instance_name":
|
||||
settings.InstanceName = v
|
||||
case "smtp_host":
|
||||
@@ -245,7 +247,8 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
('backup_time', ?),
|
||||
('backup_retention_days', ?),
|
||||
('registration_mode', ?),
|
||||
('registration_domains', ?)
|
||||
('registration_domains', ?),
|
||||
('registration_verify_email', ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
settings.SharingMode,
|
||||
regStr,
|
||||
@@ -264,6 +267,7 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error {
|
||||
strconv.Itoa(settings.BackupRetentionDays),
|
||||
settings.RegistrationMode,
|
||||
settings.RegistrationDomains,
|
||||
strconv.FormatBool(settings.RegistrationVerifyEmail),
|
||||
)
|
||||
return err
|
||||
}
|
||||
Reference in new issue
Block a user