feat(auth): 支持开放注册
- 新增注册接口与注册页面,开放注册时可创建普通用户并自动登录。 - 公共配置增加开放注册状态,登录页可跳转注册入口。 - 补充注册流程测试,验证关闭注册、账号创建与登录行为。
This commit is contained in:
@@ -268,6 +268,43 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
client := &testClient{t: t, server: ts}
|
||||
|
||||
var out map[string]any
|
||||
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, &out); code != http.StatusForbidden {
|
||||
t.Fatalf("closed registration code=%d body=%v", code, out)
|
||||
}
|
||||
|
||||
a.cfg.OpenRegistration = true
|
||||
var registered struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, ®istered); code != http.StatusCreated || registered.User.Email != "newuser@example.com" || registered.User.Role != "user" {
|
||||
t.Fatalf("register code=%d user=%+v", code, registered.User)
|
||||
}
|
||||
var me struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@example.com" {
|
||||
t.Fatalf("me code=%d user=%+v", code, me.User)
|
||||
}
|
||||
var mine struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 0 {
|
||||
t.Fatalf("registered user should not get implicit mailbox: code=%d items=%+v", code, mine.Items)
|
||||
}
|
||||
|
||||
another := &testClient{t: t, server: ts}
|
||||
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@example.com", "password": "Password123!"}, &out); code != http.StatusOK {
|
||||
t.Fatalf("login registered user code=%d body=%v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
|
||||
@@ -31,6 +31,7 @@ func (a *App) Router() http.Handler {
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/public/settings", a.handlePublicSettings)
|
||||
r.Post("/auth/register", a.handleRegister)
|
||||
r.Post("/auth/login", a.handleLogin)
|
||||
r.Post("/auth/logout", a.handleLogout)
|
||||
r.With(a.requireAuth).Get("/me", a.handleMe)
|
||||
@@ -193,6 +194,77 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.OpenRegistration {
|
||||
respondError(w, http.StatusForbidden, "registration is closed")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
TurnstileToken string `json:"turnstileToken"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "human verification failed")
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("invalid email"))
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.Split(email, "@")[0]
|
||||
}
|
||||
if len([]rune(displayName)) > 80 {
|
||||
badRequest(w, errors.New("displayName must be at most 80 characters"))
|
||||
return
|
||||
}
|
||||
if _, _, err := a.userByEmail(r.Context(), email); err == nil {
|
||||
respondError(w, http.StatusConflict, "email already registered")
|
||||
return
|
||||
} else if !errors.Is(err, errNotFound) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check user")
|
||||
return
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "email already registered")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
user, err := a.userByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create session")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
|
||||
|
||||
@@ -54,6 +54,7 @@ type systemSettingsUpdate struct {
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
@@ -74,7 +75,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if refreshSeconds <= 0 {
|
||||
refreshSeconds = 30
|
||||
}
|
||||
respondJSON(w, http.StatusOK, PublicSettings{TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000})
|
||||
respondJSON(w, http.StatusOK, PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000})
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user