Skip to content

Commit

Permalink
Add first-time Super Admin setup UI on fresh install.
Browse files Browse the repository at this point in the history
- Don't setup username+password from config file during fresh install.
- If `LISTMONK_ADMIN_USER` and `LISTMONK_ADMIN_PASSWORD` env vars are
  set during `--install`, use them.
- Otherwise, render new username+password creation UI on `/admin/login`.
- Add Cypress tests.
  • Loading branch information
knadh committed Oct 26, 2024
1 parent 1e4b3a2 commit 5b3d6e2
Show file tree
Hide file tree
Showing 11 changed files with 234 additions and 87 deletions.
136 changes: 136 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import (

"github.com/knadh/listmonk/internal/auth"
"github.com/knadh/listmonk/internal/utils"
"github.com/knadh/listmonk/models"
"github.com/labstack/echo/v4"
"github.com/zerodha/simplesessions/v3"
"gopkg.in/volatiletech/null.v6"
)

type loginTpl struct {
Expand All @@ -35,6 +37,17 @@ var oidcProviders = map[string]bool{

// handleLoginPage renders the login page and handles the login form.
func handleLoginPage(c echo.Context) error {
app := c.Get("app").(*App)

// Has the user been setup?
app.Lock()
needsUserSetup := app.needsUserSetup
app.Unlock()

if needsUserSetup {
return handleLoginSetupPage(c)
}

// Process POST login request.
var loginErr error
if c.Request().Method == http.MethodPost {
Expand All @@ -47,6 +60,26 @@ func handleLoginPage(c echo.Context) error {
return renderLoginPage(c, loginErr)
}

// handleLoginSetupPage renders the first time user login page and handles the login form.
func handleLoginSetupPage(c echo.Context) error {
app := c.Get("app").(*App)

// Process POST login request.
var loginErr error

if c.Request().Method == http.MethodPost {
loginErr = doLoginSetup(c)
if loginErr == nil {
app.Lock()
app.needsUserSetup = false
app.Unlock()
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
}
}

return renderLoginSetupPage(c, loginErr)
}

// handleLogout logs a user out.
func handleLogout(c echo.Context) error {
var (
Expand Down Expand Up @@ -189,6 +222,34 @@ func renderLoginPage(c echo.Context, loginErr error) error {
return c.Render(http.StatusOK, "admin-login", out)
}

// renderLoginSetupPage renders the first time user setup page.
func renderLoginSetupPage(c echo.Context, loginErr error) error {
var (
app = c.Get("app").(*App)
next = utils.SanitizeURI(c.FormValue("next"))
)

if next == "/" {
next = uriAdmin
}

out := loginTpl{
Title: app.i18n.T("users.login"),
PasswordEnabled: true,
NextURI: next,
}

if loginErr != nil {
if e, ok := loginErr.(*echo.HTTPError); ok {
out.Error = e.Message.(string)
} else {
out.Error = loginErr.Error()
}
}

return c.Render(http.StatusOK, "admin-login-setup", out)
}

// doLogin logs a user in with a username and password.
func doLogin(c echo.Context) error {
var (
Expand Down Expand Up @@ -233,3 +294,78 @@ func doLogin(c echo.Context) error {

return nil
}

// doLoginSetup sets a user up for the first time.
func doLoginSetup(c echo.Context) error {
var (
app = c.Get("app").(*App)
)

// Verify that the request came from the login page (CSRF).
// nonce, err := c.Cookie("nonce")
// if err != nil || nonce.Value == "" || nonce.Value != c.FormValue("nonce") {
// return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))
// }

var (
email = strings.TrimSpace(c.FormValue("email"))
username = strings.TrimSpace(c.FormValue("username"))
password = strings.TrimSpace(c.FormValue("password"))
password2 = strings.TrimSpace(c.FormValue("password2"))
)

if !utils.ValidateEmail(email) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
}
if !strHasLen(username, 3, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
}
if !strHasLen(password, 8, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
}
if password != password2 {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("users.passwordMismatch"))
}

// Create the default "Super Admin".
r := models.Role{
Type: models.RoleTypeUser,
Name: null.NewString("Super Admin", true),
}
for p := range app.constants.Permissions {
r.Permissions = append(r.Permissions, p)
}
role, err := app.core.CreateRole(r)
if err != nil {
return err
}

// Create the super admin user.
u := models.User{
Type: models.UserTypeUser,
HasPassword: true,
PasswordLogin: true,
Username: username,
Name: username,
Password: null.NewString(password, true),
Email: null.NewString(email, true),
UserRoleID: role.ID,
Status: models.UserStatusEnabled,
}
if _, err := app.core.CreateUser(u); err != nil {
return err
}

// Log the user in.
user, err := app.core.LoginUser(username, password)
if err != nil {
return err
}

// Set the session.
if err := app.auth.SaveSession(user, "", c); err != nil {
return err
}

return nil
}
9 changes: 5 additions & 4 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,7 @@ func initTplFuncs(i *i18n.I18n, cs *constants) template.FuncMap {
return funcs
}

func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth {
func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) (bool, *auth.Auth) {
var oidcCfg auth.OIDCConfig

if ko.Bool("security.oidc.enabled") {
Expand Down Expand Up @@ -976,8 +976,9 @@ func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth {
}

// Cache all API users in-memory for token auth.
if err := cacheAPIUsers(co, a); err != nil {
lo.Fatalf("error loading API users: %v", err)
hasUsers, err := cacheUsers(co, a)
if err != nil {
lo.Fatalf("error loading API users to cache: %v", err)
}

// If the legacy username+password is set in the TOML file, use that as an API
Expand All @@ -1004,5 +1005,5 @@ func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth {
lo.Println(`WARNING: Remove the admin_username and admin_password fields from the TOML configuration file. If you are using APIs, create and use new credentials. Users are now managed via the Admin -> Settings -> Users dashboard.`)
}

return a
return hasUsers, a
}
98 changes: 33 additions & 65 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (

"github.com/gofrs/uuid/v5"
"github.com/jmoiron/sqlx"
"github.com/knadh/listmonk/internal/utils"
"github.com/knadh/listmonk/models"
"github.com/knadh/stuffbin"
"github.com/lib/pq"
Expand Down Expand Up @@ -73,15 +72,24 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo
// Sample campaign.
installCampaign(campTplID, archiveTplID, q)

// Super admin role.
user, password := installUser(q)
// Setup the user optionally.
var (
user = os.Getenv("LISTMONK_ADMIN_USER")
password = os.Getenv("LISTMONK_ADMIN_PASSWORD")
)
if user != "" && password != "" {
if len(user) < 3 || len(password) < 8 {
lo.Fatal("LISTMONK_ADMIN_USER should be min 3 chars and LISTMONK_ADMIN_PASSWORD should be min 8 chars")
}

lo.Printf("creating Super Admin user '%s'", user)
installUser(user, password, q)
} else {
lo.Printf("no Super Admin user created. Visit webpage to create user.")
}

lo.Printf("setup complete")
lo.Printf(`run the program and access the dashboard at %s`, ko.MustString("app.address"))

if user != "" {
fmt.Printf("\n\033[31mIMPORTANT! CHANGE PASSWORD AFTER LOGGING IN\033[0m\nusername: \033[32m%s\033[0m and password: \033[32m%s\033[0m\n\n", user, password)
}
}

// installSchema executes the SQL schema and creates the necessary tables and types.
Expand All @@ -99,64 +107,6 @@ func installSchema(curVer string, db *sqlx.DB, fs stuffbin.FileSystem) error {
return recordMigrationVersion(curVer, db)
}

func installUser(q *models.Queries) (string, string) {
consts := initConstants()

// Super admin role.
perms := []string{}
for p := range consts.Permissions {
perms = append(perms, p)
}

if _, err := q.CreateRole.Exec("Super Admin", "user", pq.Array(perms)); err != nil {
lo.Fatalf("error creating super admin role: %v", err)
}

// Create super admin.
var (
user = os.Getenv("LISTMONK_ADMIN_USER")
password = os.Getenv("LISTMONK_ADMIN_PASSWORD")
typ = "env"
)

if user != "" {
// If the env vars are set, use those values
if len(user) < 2 || len(password) < 8 {
lo.Fatal("LISTMONK_ADMIN_USER should be min 3 chars and LISTMONK_ADMIN_PASSWORD should be min 8 chars")
}
} else if ko.Exists("app.admin_username") {
// Legacy admin/password are set in the config or env var. Use those.
user = ko.String("app.admin_username")
password = ko.String("app.admin_password")

if len(user) < 2 || len(password) < 8 {
lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars")
}
typ = "legacy config"
} else {
// None are set. Auto-generate.
user = "admin"
if p, err := utils.GenerateRandomString(12); err != nil {
lo.Fatal("error generating admin password")
} else {
password = p
}
typ = "auto-generated"
}

lo.Printf("creating admin user '%s'. Credential source is '%s'", user, typ)

if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "user", 1, nil, "enabled"); err != nil {
lo.Fatalf("error creating superadmin user: %v", err)
}

if typ == "auto-generated" {
return user, password
}

return "", ""
}

func installLists(q *models.Queries) (int, int) {
var (
defList int
Expand Down Expand Up @@ -318,3 +268,21 @@ func checkSchema(db *sqlx.DB) (bool, error) {
}
return true, nil
}

func installUser(username, password string, q *models.Queries) {
consts := initConstants()

// Super admin role.
perms := []string{}
for p := range consts.Permissions {
perms = append(perms, p)
}

if _, err := q.CreateRole.Exec("Super Admin", "user", pq.Array(perms)); err != nil {
lo.Fatalf("error creating super admin role: %v", err)
}

if _, err := q.CreateUser.Exec(username, true, password, username+"@listmonk", username, "user", 1, nil, "enabled"); err != nil {
lo.Fatalf("error creating superadmin user: %v", err)
}
}
11 changes: 10 additions & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ type App struct {
// after a settings update.
needsRestart bool

// First time installation with no user records in the DB. Needs user setup.
needsUserSetup bool

// Global state that stores data on an available remote update.
update *AppUpdate
sync.Mutex
Expand Down Expand Up @@ -212,7 +215,13 @@ func main() {
app.queries = queries
app.manager = initCampaignManager(app.queries, app.constants, app)
app.importer = initImporter(app.queries, db, app.core, app)
app.auth = initAuth(db.DB, ko, app.core)

hasUsers, auth := initAuth(db.DB, ko, app.core)
app.auth = auth
// If there are are no users in the DB who can login, the app has to prompt
// for new user setup.
app.needsUserSetup = !hasUsers

app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants)
initTxTemplates(app.manager, app)

Expand Down
6 changes: 3 additions & 3 deletions cmd/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func handleUpdateUserRole(c echo.Context) error {
}

// Cache the API token for validating API queries without hitting the DB every time.
if err := cacheAPIUsers(app.core, app.auth); err != nil {
if _, err := cacheUsers(app.core, app.auth); err != nil {
return err
}

Expand Down Expand Up @@ -153,7 +153,7 @@ func handleUpdateListRole(c echo.Context) error {
}

// Cache the API token for validating API queries without hitting the DB every time.
if err := cacheAPIUsers(app.core, app.auth); err != nil {
if _, err := cacheUsers(app.core, app.auth); err != nil {
return err
}

Expand All @@ -176,7 +176,7 @@ func handleDeleteRole(c echo.Context) error {
}

// Cache the API token for validating API queries without hitting the DB every time.
if err := cacheAPIUsers(app.core, app.auth); err != nil {
if _, err := cacheUsers(app.core, app.auth); err != nil {
return err
}

Expand Down
Loading

0 comments on commit 5b3d6e2

Please sign in to comment.