mirror of
https://github.com/knadh/listmonk.git
synced 2025-10-07 22:06:23 +08:00
- During install, listmonk now accepts the env `LISTMONK_ADMIN_API_USER` and creates an API user (with username $LISTMONK_ADMIN_API_USER) with full superadmin permissions. This requires LISTMONK_ADMIN_USER and LISTMONK_ADMIN_API_PASSWORD to be set so that that there's always a superadmin user to avoid bad states, mainly: bot superadmin exists, but no admin user exists, leaving the installation perpetually open with the superadmin user creation UI on the first login. The API user's token is printed to stderr in the following format: `export LISTMONK_ADMIN_API_TOKEN="7I81VSd90UWhKDj5Kq9c6YopToRduyDF"` This can be redirected to a file with ./listmonk 2> /tmp/token or captured directly and then source()'d. - Add new function `core.GetRole(id)`. - Fix `at least one super admin` query in user deletion.
372 lines
10 KiB
Go
372 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/mail"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/knadh/listmonk/internal/auth"
|
|
"github.com/knadh/listmonk/internal/utils"
|
|
"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
|
|
}
|
|
|
|
type oidcState struct {
|
|
Nonce string `json:"nonce"`
|
|
Next string `json:"next"`
|
|
}
|
|
|
|
var oidcProviders = map[string]bool{
|
|
"google.com": true,
|
|
"microsoftonline.com": true,
|
|
"auth0.com": true,
|
|
"github.com": true,
|
|
}
|
|
|
|
// LoginPage renders the login page and handles the login form.
|
|
func (a *App) LoginPage(c echo.Context) error {
|
|
// Has the user been setup?
|
|
a.Lock()
|
|
needsUserSetup := a.needsUserSetup
|
|
a.Unlock()
|
|
|
|
if needsUserSetup {
|
|
return a.LoginSetupPage(c)
|
|
}
|
|
|
|
// Process POST login request.
|
|
var loginErr error
|
|
if c.Request().Method == http.MethodPost {
|
|
loginErr = a.doLogin(c)
|
|
if loginErr == nil {
|
|
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
|
|
}
|
|
}
|
|
|
|
// Render the page, with or without POST.
|
|
return a.renderLoginPage(c, loginErr)
|
|
}
|
|
|
|
// LoginSetupPage renders the first time user login page and handles the login form.
|
|
func (a *App) LoginSetupPage(c echo.Context) error {
|
|
// Process POST login request.
|
|
var loginErr error
|
|
if c.Request().Method == http.MethodPost {
|
|
loginErr = a.doFirstTimeSetup(c)
|
|
if loginErr == nil {
|
|
a.Lock()
|
|
a.needsUserSetup = false
|
|
a.Unlock()
|
|
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
|
|
}
|
|
}
|
|
|
|
// Render the page, with or without POST.
|
|
return a.renderLoginSetupPage(c, loginErr)
|
|
}
|
|
|
|
// Logout logs a user out.
|
|
func (a *App) Logout(c echo.Context) error {
|
|
// Delete the session from the DB and cookie.
|
|
sess := c.Get(auth.SessionKey).(*simplesessions.Session)
|
|
_ = sess.Destroy()
|
|
|
|
return c.JSON(http.StatusOK, okResp{true})
|
|
}
|
|
|
|
// OIDCLogin initializes an OIDC request and redirects to the OIDC provider for login.
|
|
func (a *App) OIDCLogin(c echo.Context) error {
|
|
// 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, a.i18n.T("users.invalidRequest"))
|
|
}
|
|
|
|
// Sanitize the URL and make it relative.
|
|
next := utils.SanitizeURI(c.FormValue("next"))
|
|
if next == "/" {
|
|
next = uriAdmin
|
|
}
|
|
|
|
// Preparethe OIDC payload to send to the provider.
|
|
state := oidcState{Nonce: nonce.Value, Next: next}
|
|
|
|
b, err := json.Marshal(state)
|
|
if err != nil {
|
|
a.log.Printf("error marshalling OIDC state: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
|
}
|
|
|
|
// Redirect to the external OIDC provider.
|
|
return c.Redirect(http.StatusFound, a.auth.GetOIDCAuthURL(base64.URLEncoding.EncodeToString(b), nonce.Value))
|
|
}
|
|
|
|
// OIDCFinish receives the redirect callback from the OIDC provider and completes the handshake.
|
|
func (a *App) OIDCFinish(c echo.Context) error {
|
|
// Verify that the request actually originated from the login request (which sets the nonce value).
|
|
nonce, err := c.Cookie("nonce")
|
|
if err != nil || nonce.Value == "" {
|
|
return a.renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest")))
|
|
}
|
|
|
|
// Validate the OIDC token.
|
|
oidcToken, claims, err := a.auth.ExchangeOIDCToken(c.Request().URL.Query().Get("code"), nonce.Value)
|
|
if err != nil {
|
|
return a.renderLoginPage(c, err)
|
|
}
|
|
|
|
// Validate the state.
|
|
var state oidcState
|
|
stateB, err := base64.URLEncoding.DecodeString(c.QueryParam("state"))
|
|
if err != nil {
|
|
a.log.Printf("error decoding OIDC state: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
|
}
|
|
if err := json.Unmarshal(stateB, &state); err != nil {
|
|
a.log.Printf("error unmarshalling OIDC state: %v", err)
|
|
return echo.NewHTTPError(http.StatusInternalServerError, a.i18n.T("globals.messages.internalError"))
|
|
}
|
|
if state.Nonce != nonce.Value {
|
|
return a.renderLoginPage(c, echo.NewHTTPError(http.StatusUnauthorized, a.i18n.T("users.invalidRequest")))
|
|
}
|
|
|
|
// Validate e-mail from the claim.
|
|
email := strings.TrimSpace(claims.Email)
|
|
if email == "" {
|
|
return a.renderLoginPage(c, errors.New(a.i18n.Ts("globals.messages.invalidFields", "name", "email")))
|
|
}
|
|
em, err := mail.ParseAddress(email)
|
|
if err != nil {
|
|
return a.renderLoginPage(c, err)
|
|
}
|
|
email = strings.ToLower(em.Address)
|
|
|
|
// Get the user by e-mail received from OIDC.
|
|
user, err := a.core.GetUser(0, "", email)
|
|
if err != nil {
|
|
return a.renderLoginPage(c, err)
|
|
}
|
|
|
|
// Update the user login state (avatar, logged in date) in the DB.
|
|
if err := a.core.UpdateUserLogin(user.ID, claims.Picture); err != nil {
|
|
return a.renderLoginPage(c, err)
|
|
}
|
|
|
|
// Set the session in the DB and cookie.
|
|
if err := a.auth.SaveSession(user, oidcToken, c); err != nil {
|
|
return a.renderLoginPage(c, err)
|
|
}
|
|
|
|
// Redirect to the next page.
|
|
return c.Redirect(http.StatusFound, utils.SanitizeURI(state.Next))
|
|
}
|
|
|
|
// renderLoginPage renders the login page and handles the login form.
|
|
func (a *App) renderLoginPage(c echo.Context, loginErr error) error {
|
|
next := utils.SanitizeURI(c.FormValue("next"))
|
|
if next == "/" {
|
|
next = uriAdmin
|
|
}
|
|
|
|
var (
|
|
oidcProvider = ""
|
|
oidcLogo = ""
|
|
)
|
|
if a.cfg.Security.OIDC.Enabled {
|
|
oidcLogo = "oidc.png"
|
|
u, err := url.Parse(a.cfg.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()
|
|
}
|
|
|
|
// Lookup the logo in the known providers map.
|
|
if _, ok := oidcProviders[oidcProvider]; ok {
|
|
oidcLogo = oidcProvider + ".png"
|
|
}
|
|
}
|
|
}
|
|
|
|
out := loginTpl{
|
|
Title: a.i18n.T("users.login"),
|
|
PasswordEnabled: true,
|
|
OIDCProvider: oidcProvider,
|
|
OIDCProviderLogo: oidcLogo,
|
|
NextURI: next,
|
|
}
|
|
|
|
// If there was an error in the previous state (POST reqest), set it to render in the template.
|
|
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 that will be valided in the subsequent requests.
|
|
nonce, err := utils.GenerateRandomString(16)
|
|
if err != nil {
|
|
a.log.Printf("error generating OIDC nonce: %v", err)
|
|
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("globals.messages.internalError"))
|
|
}
|
|
c.SetCookie(&http.Cookie{
|
|
Name: "nonce",
|
|
Value: nonce,
|
|
HttpOnly: true,
|
|
Path: "/",
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
out.Nonce = nonce
|
|
|
|
// Render the login page.
|
|
return c.Render(http.StatusOK, "admin-login", out)
|
|
}
|
|
|
|
// renderLoginSetupPage renders the first time user setup page.
|
|
func (a *App) renderLoginSetupPage(c echo.Context, loginErr error) error {
|
|
next := utils.SanitizeURI(c.FormValue("next"))
|
|
if next == "/" {
|
|
next = uriAdmin
|
|
}
|
|
|
|
out := loginTpl{
|
|
Title: a.i18n.T("users.login"),
|
|
PasswordEnabled: true,
|
|
NextURI: next,
|
|
}
|
|
|
|
// If there was an error in the previous state (POST reqest), set it to render in the template.
|
|
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 (a *App) doLogin(c echo.Context) error {
|
|
var (
|
|
username = strings.TrimSpace(c.FormValue("username"))
|
|
password = strings.TrimSpace(c.FormValue("password"))
|
|
)
|
|
|
|
if !strHasLen(username, 3, stdInputMaxLen) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
|
}
|
|
if !strHasLen(password, 8, stdInputMaxLen) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
|
}
|
|
|
|
// Log the user in by fetching and verifying credentials from the DB.
|
|
user, err := a.core.LoginUser(username, password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Resist potential constant-time-comparison attacks with a min response time.
|
|
if ms := time.Since(time.Now()).Milliseconds(); ms < 100 {
|
|
time.Sleep(time.Duration(ms))
|
|
}
|
|
|
|
// Set the session in the DB and cookie.
|
|
if err := a.auth.SaveSession(user, "", c); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// doFirstTimeSetup sets a user up for the first time.
|
|
func (a *App) doFirstTimeSetup(c echo.Context) error {
|
|
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, a.i18n.Ts("globals.messages.invalidFields", "name", "email"))
|
|
}
|
|
if !strHasLen(username, 3, stdInputMaxLen) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
|
}
|
|
if !strHasLen(password, 8, stdInputMaxLen) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
|
}
|
|
if password != password2 {
|
|
return echo.NewHTTPError(http.StatusBadRequest, a.i18n.T("users.passwordMismatch"))
|
|
}
|
|
|
|
// Create the default "Super Admin" with all permissions if it doesn't exist.
|
|
if _, err := a.core.GetRole(auth.SuperAdminRoleID); err != nil {
|
|
r := auth.Role{
|
|
Type: auth.RoleTypeUser,
|
|
Name: null.NewString("Super Admin", true),
|
|
}
|
|
for p := range a.cfg.Permissions {
|
|
r.Permissions = append(r.Permissions, p)
|
|
}
|
|
|
|
// Create the role in the DB.
|
|
if _, err := a.core.CreateRole(r); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Create the super admin user in the DB.
|
|
u := auth.User{
|
|
Type: auth.UserTypeUser,
|
|
HasPassword: true,
|
|
PasswordLogin: true,
|
|
Username: username,
|
|
Name: username,
|
|
Password: null.NewString(password, true),
|
|
Email: null.NewString(email, true),
|
|
UserRoleID: auth.SuperAdminRoleID,
|
|
Status: auth.UserStatusEnabled,
|
|
}
|
|
if _, err := a.core.CreateUser(u); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Log the user in directly.
|
|
user, err := a.core.LoginUser(username, password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Set the session in the DB and cookie.
|
|
if err := a.auth.SaveSession(user, "", c); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|