feat: use usernames for administrator accounts
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
This commit is contained in:
@@ -108,7 +108,13 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
loginName, err := cleanLoginName(req.LoginName, req.Email)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
}
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -183,6 +189,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
LoginName string `json:"loginName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
@@ -211,6 +218,15 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
requestedLoginName := strings.TrimSpace(req.LoginName)
|
||||
loginName := existing.LoginName
|
||||
if requestedLoginName != "" {
|
||||
loginName, err = cleanUsername(requestedLoginName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
@@ -278,8 +294,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
emailIdentity := existing.Email
|
||||
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
|
||||
emailIdentity = loginName
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
badRequest(w, errors.New("登录名已被使用"))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1410,6 +1410,18 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if strings.TrimSpace(a.cfg.AdminUsername) != "" {
|
||||
adminUsername, err := cleanUsername(a.cfg.AdminUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid admin username: %w", err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
|
||||
return nil
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return errors.New("invalid admin email")
|
||||
@@ -1451,6 +1463,11 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
|
||||
return err
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
|
||||
@@ -1421,6 +1421,69 @@ func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsernameBootstrapDoesNotCreateMailboxAndCanBeRenamed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Config{
|
||||
Addr: ":0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: filepath.Join(dir, "data"),
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminUsername: "admin",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
}
|
||||
a := newTestAppWithConfig(t, cfg)
|
||||
|
||||
var domains, mailboxes int
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM domains`).Scan(&domains); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.db.QueryRow(`SELECT COUNT(*) FROM mailboxes`).Scan(&mailboxes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if domains != 0 || mailboxes != 0 {
|
||||
t.Fatalf("username bootstrap created domains=%d mailboxes=%d", domains, mailboxes)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("username login code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "rootadmin",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusOK {
|
||||
t.Fatalf("rename administrator code=%d", code)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+login.User.ID, map[string]any{
|
||||
"loginName": "root@example.test",
|
||||
"displayName": "Administrator",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}, nil); code != http.StatusBadRequest {
|
||||
t.Fatalf("email-shaped login name code=%d", code)
|
||||
}
|
||||
|
||||
oldLogin := &testClient{t: t, server: ts}
|
||||
if code := oldLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "admin", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
|
||||
t.Fatalf("old username login code=%d", code)
|
||||
}
|
||||
newLogin := &testClient{t: t, server: ts}
|
||||
if code := newLogin.do("POST", "/api/auth/login", map[string]string{"loginName": "rootadmin", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("renamed username login code=%d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
|
||||
@@ -50,7 +50,13 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
loginName, err := cleanLoginName(req.LoginName, req.Email)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
|
||||
@@ -14,6 +14,7 @@ type Config struct {
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
@@ -70,6 +71,7 @@ func LoadConfig() Config {
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
|
||||
@@ -1057,6 +1057,9 @@ func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
}
|
||||
|
||||
@@ -187,6 +187,17 @@ func cleanLoginName(value string, fallbacks ...string) (string, error) {
|
||||
return loginName, nil
|
||||
}
|
||||
|
||||
func cleanUsername(value string) (string, error) {
|
||||
username, err := cleanLoginName(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(username, "@") {
|
||||
return "", errors.New("登录名不能使用邮箱地址")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func dedupeEmails(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(items))
|
||||
|
||||
Reference in New Issue
Block a user