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)
|
||||
|
||||
Reference in new issue
Block a user