listmonk/cmd/auth.go
Kailash Nadh 5b3d6e2be0 Add first-time Super Admin setup UI on fresh install.
- 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.
2024-10-26 19:47:01 +05:30

371 lines
9.4 KiB
Go

package main
import (
"errors"
"net/http"
"net/mail"
"net/url"
"strings"
"time"
"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 {
Title string
Description string
NextURI string
Nonce string
PasswordEnabled bool
OIDCProvider string
OIDCProviderLogo string
Error string
}
var oidcProviders = map[string]bool{
"google.com": true,
"microsoftonline.com": true,
"auth0.com": true,
"github.com": true,
}
// 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 {
loginErr = doLogin(c)
if loginErr == nil {
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
}
}
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 (
sess = c.Get(auth.SessionKey).(*simplesessions.Session)
)
// Clear the session.
_ = sess.Destroy()
return c.JSON(http.StatusOK, okResp{true})
}
// handleOIDCLogin initializes an OIDC request and redirects to the OIDC provider for login.
func handleOIDCLogin(c echo.Context) error {
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"))
}
next := utils.SanitizeURI(c.FormValue("next"))
if next == "/" {
next = uriAdmin
}
return c.Redirect(http.StatusFound, app.auth.GetOIDCAuthURL(next, nonce.Value))
}
// handleOIDCFinish receives the redirect callback from the OIDC provider and completes the handshake.
func handleOIDCFinish(c echo.Context) error {
app := c.Get("app").(*App)
nonce, err := c.Cookie("nonce")
if err != nil || nonce.Value == "" {
return renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest")))
}
// Validate the OIDC token.
oidcToken, claims, err := app.auth.ExchangeOIDCToken(c.Request().URL.Query().Get("code"), nonce.Value)
if err != nil {
return renderLoginPage(c, err)
}
// Validate e-mail from the claim.
email := strings.TrimSpace(claims.Email)
if email == "" {
return renderLoginPage(c, errors.New(app.i18n.Ts("globals.messages.invalidFields", "name", "email")))
}
em, err := mail.ParseAddress(email)
if err != nil {
return renderLoginPage(c, err)
}
email = strings.ToLower(em.Address)
// Get the user by e-mail received from OIDC.
user, err := app.core.GetUser(0, "", email)
if err != nil {
return renderLoginPage(c, err)
}
// Update user login.
if err := app.core.UpdateUserLogin(user.ID, claims.Picture); err != nil {
return renderLoginPage(c, err)
}
// Set the session.
if err := app.auth.SaveSession(user, oidcToken, c); err != nil {
return renderLoginPage(c, err)
}
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.QueryParam("state")))
}
// renderLoginPage renders the login page and handles the login form.
func renderLoginPage(c echo.Context, loginErr error) error {
var (
app = c.Get("app").(*App)
next = utils.SanitizeURI(c.FormValue("next"))
)
if next == "/" {
next = uriAdmin
}
oidcProvider := ""
oidcProviderLogo := ""
if app.constants.Security.OIDC.Enabled {
oidcProviderLogo = "oidc.png"
u, err := url.Parse(app.constants.Security.OIDC.Provider)
if err == nil {
h := strings.Split(u.Hostname(), ".")
// Get the last two h for the root domain
if len(h) >= 2 {
oidcProvider = h[len(h)-2] + "." + h[len(h)-1]
} else {
oidcProvider = u.Hostname()
}
if _, ok := oidcProviders[oidcProvider]; ok {
oidcProviderLogo = oidcProvider + ".png"
}
}
}
out := loginTpl{
Title: app.i18n.T("users.login"),
PasswordEnabled: true,
OIDCProvider: oidcProvider,
OIDCProviderLogo: oidcProviderLogo,
NextURI: next,
}
if loginErr != nil {
if e, ok := loginErr.(*echo.HTTPError); ok {
out.Error = e.Message.(string)
} else {
out.Error = loginErr.Error()
}
}
// Generate and set a nonce for preventing CSRF requests.
nonce, err := utils.GenerateRandomString(16)
if err != nil {
app.log.Printf("error generating OIDC nonce: %v", err)
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.T("globals.messages.internalError"))
}
c.SetCookie(&http.Cookie{
Name: "nonce",
Value: nonce,
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteLaxMode,
})
out.Nonce = nonce
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 (
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 (
username = strings.TrimSpace(c.FormValue("username"))
password = strings.TrimSpace(c.FormValue("password"))
)
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"))
}
start := time.Now()
user, err := app.core.LoginUser(username, password)
if err != nil {
return err
}
// Resist potential constant-time-comparison attacks with a min response time.
if ms := time.Now().Sub(start).Milliseconds(); ms < 100 {
time.Sleep(time.Duration(ms))
}
// Set the session.
if err := app.auth.SaveSession(user, "", c); err != nil {
return err
}
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
}