listmonk/cmd/auth.go
2024-10-13 16:59:51 +05:30

207 lines
5.2 KiB
Go

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 {
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,
}
// Login request.
if c.Request().Method == http.MethodPost {
err := doLogin(c)
if err == nil {
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.FormValue("next")))
}
if e, ok := err.(*echo.HTTPError); ok {
out.Error = e.Message.(string)
} else {
out.Error = err.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,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
out.Nonce = nonce
return c.Render(http.StatusOK, "admin-login", out)
}
// 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})
}
// 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.SetSession(user, "", c); err != nil {
return err
}
return nil
}
// 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"))
}
return c.Redirect(http.StatusFound, app.auth.GetOIDCAuthURL(c.Request().URL.RequestURI(), 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 echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))
}
code := c.Request().URL.Query().Get("code")
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, app.i18n.T("users.invalidRequest"))
}
oidcToken, u, err := app.auth.ExchangeOIDCToken(code, nonce.Value)
if err != nil {
return err
}
// Get the user by e-mail received from OIDC.
user, err := app.core.GetUser(0, "", u.Email.String)
if err != nil {
return err
}
// Set the session.
if err := app.auth.SetSession(user, oidcToken, c); err != nil {
return err
}
return c.Redirect(http.StatusFound, utils.SanitizeURI(c.QueryParam("state")))
}