Skip to content

Commit

Permalink
Merge pull request #2082 from knadh/multiuser
Browse files Browse the repository at this point in the history
Add multi-user auth, permissions, and user management.
  • Loading branch information
knadh authored Oct 13, 2024
2 parents 5074987 + 39463d7 commit 6fe47b2
Show file tree
Hide file tree
Showing 105 changed files with 6,905 additions and 1,269 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ FRONTEND_DEPS = \

BIN := listmonk
STATIC := config.toml.sample \
schema.sql queries.sql \
schema.sql queries.sql permissions.json \
static/public:/public \
static/email-templates \
frontend/dist:/admin \
Expand All @@ -38,7 +38,7 @@ $(FRONTEND_YARN_MODULES): frontend/package.json frontend/yarn.lock
touch -c $(FRONTEND_YARN_MODULES)

# Build the backend to ./listmonk.
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum
$(BIN): $(shell find . -type f -name "*.go") go.mod go.sum schema.sql queries.sql permissions.json
CGO_ENABLED=0 go build -o ${BIN} -ldflags="-s -w -X 'main.buildString=${BUILDSTR}' -X 'main.versionString=${VERSION}'" cmd/*.go

# Run the backend in dev mode. The frontend assets in dev mode are loaded from disk from frontend/dist.
Expand Down
26 changes: 18 additions & 8 deletions cmd/admin.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"encoding/json"
"fmt"
"net/http"
"sort"
Expand All @@ -11,20 +12,30 @@ import (
)

type serverConfig struct {
Messengers []string `json:"messengers"`
Langs []i18nLang `json:"langs"`
Lang string `json:"lang"`
Update *AppUpdate `json:"update"`
NeedsRestart bool `json:"needs_restart"`
Version string `json:"version"`
RootURL string `json:"root_url"`
FromEmail string `json:"from_email"`
Messengers []string `json:"messengers"`
Langs []i18nLang `json:"langs"`
Lang string `json:"lang"`
Permissions json.RawMessage `json:"permissions"`
Update *AppUpdate `json:"update"`
NeedsRestart bool `json:"needs_restart"`
HasLegacyUser bool `json:"has_legacy_user"`
Version string `json:"version"`
}

// handleGetServerConfig returns general server config.
func handleGetServerConfig(c echo.Context) error {
var (
app = c.Get("app").(*App)
out = serverConfig{}
)
out := serverConfig{
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
Lang: app.constants.Lang,
Permissions: app.constants.PermissionsRaw,
HasLegacyUser: app.constants.HasLegacyUser,
}

// Language list.
langList, err := getI18nLangList(app.constants.Lang, app)
Expand All @@ -33,7 +44,6 @@ func handleGetServerConfig(c echo.Context) error {
fmt.Sprintf("Error loading language list: %v", err))
}
out.Langs = langList
out.Lang = app.constants.Lang

// Sort messenger names with `email` always as the first item.
var names []string
Expand Down
221 changes: 221 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package main

import (
"net/http"
"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"
)

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 {
// 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)
}

// 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)
}

// Get the user by e-mail received from OIDC.
user, err := app.core.GetUser(0, "", claims.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)
}

// 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
}
Loading

0 comments on commit 6fe47b2

Please sign in to comment.