From 57ac9dca4b62e46c7a868876266be57964fc9d1e Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Thu, 23 May 2024 11:54:10 +0530 Subject: [PATCH] Add public login page and auth middleware and handlers. --- cmd/auth.go | 176 ++++++++++++++ cmd/init.go | 47 ++++ cmd/main.go | 3 + cmd/users.go | 56 ++--- go.mod | 5 + go.sum | 71 +++++- i18n/en.json | 4 +- internal/auth/auth.go | 353 +++++++++++++++++++++-------- internal/core/users.go | 12 +- internal/migrations/v3.1.0.go | 7 + internal/utils/utils.go | 19 ++ models/models.go | 20 +- models/queries.go | 1 + queries.sql | 10 + schema.sql | 8 + static/public/static/style.css | 5 +- static/public/templates/login.html | 39 ++++ 17 files changed, 674 insertions(+), 162 deletions(-) create mode 100644 cmd/auth.go create mode 100644 static/public/templates/login.html diff --git a/cmd/auth.go b/cmd/auth.go new file mode 100644 index 00000000..e21839de --- /dev/null +++ b/cmd/auth.go @@ -0,0 +1,176 @@ +package main + +import ( + "net/http" + "strings" + "time" + + "github.com/knadh/listmonk/internal/utils" + "github.com/labstack/echo/v4" + "github.com/vividvilla/simplesessions" +) + +type loginTpl struct { + Title string + Description string + + NextURI string + Nonce string + PasswordEnabled bool + OIDCEnabled bool + Error string +} + +// 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 + } + + out := loginTpl{ + Title: app.i18n.T("users.login"), + PasswordEnabled: true, + OIDCEnabled: true, + 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) +} + +// handleLogoutPage logs a user out. +func handleLogoutPage(c echo.Context) error { + var ( + app = c.Get("app").(*App) + sess = c.Get("session").(*simplesessions.Session) + ) + + // Clear the session. + _ = sess.Clear() + + return c.Redirect(http.StatusFound, app.constants.RootURL) +} + +// 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"))) +} diff --git a/cmd/init.go b/cmd/init.go index 5ef2d7e0..3620e9fc 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -3,6 +3,7 @@ package main import ( "bytes" "crypto/md5" + "database/sql" "encoding/json" "errors" "fmt" @@ -28,6 +29,7 @@ import ( "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/providers/posflag" "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/bounce/mailbox" "github.com/knadh/listmonk/internal/captcha" @@ -905,3 +907,48 @@ func initTplFuncs(i *i18n.I18n, cs *constants) template.FuncMap { return funcs } + +func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth { + var oidcCfg auth.OIDCConfig + + if ko.Bool("security.oidc.enabled") { + oidcCfg = auth.OIDCConfig{ + ProviderURL: ko.String("security.oidc.provider_url"), + ClientID: ko.String("security.oidc.client_id"), + ClientSecret: ko.String("security.oidc.client_secret"), + RedirectURL: ko.String("security.oidc.redirect_url"), + Skipper: func(c echo.Context) bool { + // Skip OIDC check if the request is already BasicAuth'd. + // This context flag is set in basicAuth(). + return c.Get(basicAuthd) != nil + }, + } + } + + // Session manager callbacks for getting and setting cookies. + cb := &auth.Callbacks{ + GetCookie: func(name string, r interface{}) (*http.Cookie, error) { + c := r.(echo.Context) + cookie, err := c.Cookie(name) + return cookie, err + }, + SetCookie: func(cookie *http.Cookie, w interface{}) error { + c := w.(echo.Context) + c.SetCookie(cookie) + return nil + }, + GetUser: func(id int) (models.User, error) { + return co.GetUser(id, "", "") + }, + } + + a, err := auth.New(auth.Config{ + OIDC: oidcCfg, + LoginURL: path.Join(uriAdmin, "/login"), + }, db, cb, lo) + if err != nil { + lo.Fatalf("error initializing auth: %v", err) + } + + return a +} diff --git a/cmd/main.go b/cmd/main.go index 2d51f205..82cc1d46 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -15,6 +15,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/knadh/koanf/providers/env" "github.com/knadh/koanf/v2" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/bounce" "github.com/knadh/listmonk/internal/buflog" "github.com/knadh/listmonk/internal/captcha" @@ -44,6 +45,7 @@ type App struct { manager *manager.Manager importer *subimporter.Importer messengers map[string]manager.Messenger + auth *auth.Auth media media.Store i18n *i18n.I18n bounce *bounce.Manager @@ -210,6 +212,7 @@ 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) app.notifTpls = initNotifTemplates("/email-templates/*.html", fs, app.i18n, app.constants) initTxTemplates(app.manager, app) diff --git a/cmd/users.go b/cmd/users.go index fbcf470d..308400c2 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -5,7 +5,6 @@ import ( "regexp" "strconv" "strings" - "time" "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" @@ -31,10 +30,13 @@ func handleGetUsers(c echo.Context) error { } if single { - out, err := app.core.GetUser(userID) + out, err := app.core.GetUser(userID, "", "") if err != nil { return err } + + out.Password = null.String{} + return c.JSON(http.StatusOK, okResp{out}) } @@ -44,6 +46,10 @@ func handleGetUsers(c echo.Context) error { return err } + for n := range out { + out[n].Password = null.String{} + } + return c.JSON(http.StatusOK, okResp{out}) } @@ -63,7 +69,7 @@ func handleCreateUser(c echo.Context) error { email := strings.TrimSpace(u.Email.String) // Validate fields. - if !strHasLen(u.Username, 1, stdInputMaxLen) { + if !strHasLen(u.Username, 3, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) } if !reUsername.MatchString(u.Username) { @@ -91,6 +97,7 @@ func handleCreateUser(c echo.Context) error { if err != nil { return err } + out.Password = null.String{} return c.JSON(http.StatusOK, okResp{out}) } @@ -118,7 +125,7 @@ func handleUpdateUser(c echo.Context) error { email := strings.TrimSpace(u.Email.String) // Validate fields. - if !strHasLen(u.Username, 1, stdInputMaxLen) { + if !strHasLen(u.Username, 3, stdInputMaxLen) { return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) } if !reUsername.MatchString(u.Username) { @@ -140,7 +147,7 @@ func handleUpdateUser(c echo.Context) error { } } else { // Get the existing user for password validation. - user, err := app.core.GetUser(id) + user, err := app.core.GetUser(id, "", "") if err != nil { return err } @@ -164,6 +171,7 @@ func handleUpdateUser(c echo.Context) error { if err != nil { return err } + out.Password = null.String{} return c.JSON(http.StatusOK, okResp{out}) } @@ -190,41 +198,3 @@ func handleDeleteUsers(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } - -// handleLoginUser logs a user in with a username and password. -func handleLoginUser(c echo.Context) error { - var ( - app = c.Get("app").(*App) - ) - - u := struct { - Username string `json:"username"` - Password string `json:"password"` - }{} - - if !strHasLen(u.Username, 1, stdInputMaxLen) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username")) - } - - if !strHasLen(u.Password, 8, stdInputMaxLen) { - return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password")) - } - - start := time.Now() - - _, err := app.core.LoginUser(u.Username, u.Password) - if err != nil { - return err - } - - // While realistically the app will only have a tiny fraction of users and get operations - // on the user table will be instantatneous for IDs that exist or not, always respond after - // a minimum wait of 100ms (which is again, realistically, an order of magnitude or two more - // than what it wouldt take to complete the op) to simulate constant-time-comparison to address - // any possible timing attacks. - if ms := time.Now().Sub(start).Milliseconds(); ms < 100 { - time.Sleep(time.Duration(ms)) - } - - return c.JSON(http.StatusOK, okResp{true}) -} diff --git a/go.mod b/go.mod index cd57011d..1b9b5286 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,9 @@ require ( github.com/paulbellamy/ratecounter v0.2.0 github.com/rhnvrm/simples3 v0.8.3 github.com/spf13/pflag v1.0.5 + github.com/vividvilla/simplesessions v0.2.0 + github.com/vividvilla/simplesessions/stores/postgres v1.3.0 + github.com/vividvilla/simplesessions/v2 v2.0.1 github.com/yuin/goldmark v1.6.0 github.com/zerodha/easyjson v1.0.0 golang.org/x/mod v0.17.0 @@ -72,3 +75,5 @@ require ( ) replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.8 + +replace github.com/vividvilla/simplesessions/v2 => /home/kailash/code/go/my/github.com/simplesessions diff --git a/go.sum b/go.sum index 656dd642..068227f7 100644 --- a/go.sum +++ b/go.sum @@ -4,12 +4,19 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis v2.5.0+incompatible/go.mod h1:8HZjEj4yU0dwhYHky+DxYx+6BMjkBbe5ONFIF1MXffk= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/coreos/go-oidc/v3 v3.9.0 h1:0J/ogVOd4y8P0f0xUh8l9t07xRP/d8tccvjHl2dcsSo= github.com/coreos/go-oidc/v3 v3.9.0/go.mod h1:rTKz2PYwftcrtoCzV5g5kvfJoWcm0Mk8AF8y1iAQro4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= @@ -18,12 +25,15 @@ github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKk github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= github.com/gdgvda/cron v0.2.0 h1:oX8qdLZq4tC5StnCsZsTNs2BIzaRjcjmPZ4o+BArKX4= github.com/gdgvda/cron v0.2.0/go.mod h1:VEwidZXB255kESB5DcUGRWTYZS8KkOBYD1YBn8Wiyx8= github.com/go-jose/go-jose/v3 v3.0.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA= github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +github.com/go-redis/redis/v8 v8.5.0/go.mod h1:YmEcgBDttjnkbMzDAhDtQxY9yVA7jMN6PCR5HeMvqFE= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= @@ -31,12 +41,24 @@ github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M= github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomodule/redigo v1.8.3/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -44,6 +66,7 @@ github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/feeds v1.1.1 h1:HwKXxqzcRNg9to+BbvJog4+f3s/xzvtZXICcQGutYfY= github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= @@ -110,6 +133,13 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= github.com/paulbellamy/ratecounter v0.2.0 h1:2L/RhJq+HA8gBQImDXtLPrDXK5qAj6ozWVK/zFXVJGs= github.com/paulbellamy/ratecounter v0.2.0/go.mod h1:Hfx1hDpSGoqxkVVpBi/IlYD7kChlfo5C6hzIHwPqfFE= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= @@ -138,18 +168,27 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vividvilla/simplesessions v0.2.0 h1:OQWW/329cCzH51aRJmiOioO7ZOwUscMQHvw88pPVQPI= +github.com/vividvilla/simplesessions v0.2.0/go.mod h1:561v58vYoZJN3IPikAAl8IC7KH90gz1BLV4ejBweJ70= +github.com/vividvilla/simplesessions/stores/postgres v1.3.0 h1:AvkdkyfFcAbIBpimScmkFdA9QiQRsabmcm8gPlNsO/k= +github.com/vividvilla/simplesessions/stores/postgres v1.3.0/go.mod h1:BW7RbzEV2o94MvckR2M/8FMhCC/zzZqqLvUxg+wWLeI= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v0.0.0-20200816102855-ee81675732da/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA= github.com/zerodha/easyjson v1.0.0 h1:3u1lvS8C+8ntnb4lXHc7ZzfQ8txUdzBAH5t9AwF7bUs= github.com/zerodha/easyjson v1.0.0/go.mod h1:mA8d8Xs8Yp4Q95ppRb4dRGROERgKSLQIK9Y7iuC5mog= +go.opentelemetry.io/otel v0.16.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= @@ -157,12 +196,17 @@ golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOM golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= @@ -170,11 +214,21 @@ golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -200,21 +254,34 @@ golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b h1:P+3+n9hUbqSDkSdtusWHVPQRrpRpLiLFzlZ02xXskM0= gopkg.in/volatiletech/null.v6 v6.0.0-20170828023728-0bef4e07ae1b/go.mod h1:0LRKfykySnChgQpG3Qpk+bkZFWazQ+MMfc5oldQCwnY= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/i18n/en.json b/i18n/en.json index 27553d40..3cc870a2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -596,6 +596,7 @@ "templates.rawHTML": "Raw HTML", "templates.subject": "Subject", "users.login": "Login", + "users.loginOIDC": "Login with OIDC", "users.logout": "Logout", "users.lastLogin": "Last login", "users.newUser": "New user", @@ -608,7 +609,8 @@ "users.username": "Username", "users.usernameHelp": "Used with password login", "users.password": "Password", - "users.invalidLogin": "Invalid username or password", + "users.invalidLogin": "Invalid login or password", + "users.invalidRequest": "Invalid auth request", "users.passwordRepeat": "Repeat password", "users.passwordEnable": "Enable password login", "users.passwordMismatch": "Passwords don't match", diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 0cd50597..20798357 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -2,30 +2,34 @@ package auth import ( "context" - "crypto/rand" "crypto/subtle" + "database/sql" "encoding/base64" + "errors" "fmt" - "io" + "log" "net/http" + "net/url" + "strings" "sync" + "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/vividvilla/simplesessions/stores/postgres" + "github.com/vividvilla/simplesessions/v2" "golang.org/x/oauth2" ) // UserKey is the key on which the User profile is set on echo handlers. const UserKey = "auth_user" -// User struct holds the email and name of the authenticatd user. -// It's attached to the echo handler. -type User struct { - Email string `json:"name"` - Name string `json:"email"` - Picture string `json:"picture"` -} +const ( + sessTypeNative = "native" + sessTypeOIDC = "oidc" +) type OIDCConfig struct { Enabled bool `json:"enabled"` @@ -47,144 +51,293 @@ type BasicAuthConfig struct { type Config struct { OIDC OIDCConfig BasicAuth BasicAuthConfig + LoginURL string +} + +// Callbacks takes two callback functions required by simplesessions. +type Callbacks struct { + SetCookie func(cookie *http.Cookie, w interface{}) error + GetCookie func(name string, r interface{}) (*http.Cookie, error) + GetUser func(id int) (models.User, error) } type Auth struct { - tokens map[string][]byte + tokens map[string]models.User sync.RWMutex - cfg oauth2.Config - verifier *oidc.IDTokenVerifier - skipper middleware.Skipper + cfg Config + oauthCfg oauth2.Config + verifier *oidc.IDTokenVerifier + skipper middleware.Skipper + sess *simplesessions.Manager + sessStore *postgres.Store + cb *Callbacks + log *log.Logger } -func New(cfg Config) *Auth { - provider, err := oidc.NewProvider(context.Background(), cfg.OIDC.ProviderURL) - if err != nil { - panic(err) +func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) { + a := &Auth{ + cfg: cfg, + cb: cb, + log: lo, } - verifier := provider.Verifier(&oidc.Config{ - ClientID: cfg.OIDC.ClientID, + + // Initialize OIDC. + if cfg.OIDC.Enabled { + provider, err := oidc.NewProvider(context.Background(), cfg.OIDC.ProviderURL) + if err != nil { + panic(err) + } + + a.verifier = provider.Verifier(&oidc.Config{ + ClientID: cfg.OIDC.ClientID, + }) + + a.oauthCfg = oauth2.Config{ + ClientID: cfg.OIDC.ClientID, + ClientSecret: cfg.OIDC.ClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: cfg.OIDC.RedirectURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + + a.skipper = cfg.OIDC.Skipper + } + + // Initialize session manager. + a.sess = simplesessions.New(simplesessions.Options{ + IsHTTPOnlyCookie: true, + CookieLifetime: time.Hour * 24 * 7, }) - - oidcConfig := oauth2.Config{ - ClientID: cfg.OIDC.ClientID, - ClientSecret: cfg.OIDC.ClientSecret, - Endpoint: provider.Endpoint(), - RedirectURL: cfg.OIDC.RedirectURL, - Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + st, err := postgres.New(postgres.Opt{}, db) + if err != nil { + return nil, err } + a.sessStore = st + a.sess.UseStore(st) + a.sess.RegisterGetCookie(cb.GetCookie) + a.sess.RegisterSetCookie(cb.SetCookie) - return &Auth{ - verifier: verifier, - cfg: oidcConfig, - skipper: cfg.OIDC.Skipper, - } + // Prune dead sessions from the DB periodically. + go func() { + if err := st.Prune(); err != nil { + lo.Printf("error pruning login sessions: %v", err) + } + time.Sleep(time.Hour * 12) + }() + + return a, nil } // SetTokens caches tokens for authenticating API client calls. -func (o *Auth) SetAPITokens(tokens map[string][]byte) { +func (o *Auth) SetTokens(tokens map[string]models.User) { o.Lock() defer o.Unlock() - o.tokens = make(map[string][]byte, len(tokens)) - for user, token := range tokens { - o.tokens[user] = []byte{} - copy(o.tokens[user], token) + o.tokens = make(map[string]models.User, len(tokens)) + for userID, u := range tokens { + o.tokens[userID] = u } } -// CheckAPIToken validates an API user+token. -func (o *Auth) CheckAPIToken(user string, token []byte) bool { +// GetToken validates an API user+token. +func (o *Auth) GetToken(user string, token string) (models.User, bool) { o.RLock() t, ok := o.tokens[user] o.RUnlock() - return ok && subtle.ConstantTimeCompare(t, token) == 1 + if !ok || subtle.ConstantTimeCompare([]byte(t.Password.String), []byte(token)) != 1 { + return models.User{}, false + } + + return t, true } -// HandleOIDCCallback is the HTTP handler that handles the post-OIDC provider redirect callback. -func (o *Auth) HandleOIDCCallback(c echo.Context) error { - tk, err := o.cfg.Exchange(c.Request().Context(), c.Request().URL.Query().Get("code")) +// GetOIDCAuthURL returns the OIDC provider's auth URL to redirect to. +func (o *Auth) GetOIDCAuthURL(state, nonce string) string { + return o.oauthCfg.AuthCodeURL(state, oidc.Nonce(nonce)) +} + +// ExchangeOIDCToken takes an OIDC authorization code (recieved via redirect from the OIDC provider), +// validates it, and returns an OIDC token for subsequent auth. +func (o *Auth) ExchangeOIDCToken(code, nonce string) (string, models.User, error) { + var user models.User + + tk, err := o.oauthCfg.Exchange(context.TODO(), code) if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error exchanging token: %v", err)) + return "", user, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error exchanging token: %v", err)) } rawIDTk, ok := tk.Extra("id_token").(string) if !ok { - return echo.NewHTTPError(http.StatusUnauthorized, "`id_token` missing.") + return "", user, echo.NewHTTPError(http.StatusUnauthorized, "`id_token` missing.") } - // idTk, err := o.verifier.Verify(c.Request().Context(), rawIDTk) - // if err != nil { - // return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error verifying ID token: %v", err)) - // } + idTk, err := o.verifier.Verify(context.TODO(), rawIDTk) + if err != nil { + return "", user, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error verifying ID token: %v", err)) + } - // nonce, err := c.Cookie("nonce") - // if err != nil { - // return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("nonce cookie not found: %v", err)) - // } + if idTk.Nonce != nonce { + return "", user, echo.NewHTTPError(http.StatusUnauthorized, "nonce did not match") + } - // if idTk.Nonce != nonce.Value { - // return echo.NewHTTPError(http.StatusUnauthorized, "nonce did not match") - // } + if err := idTk.Claims(&user); err != nil { + return "", user, errors.New("error getting user from OIDC") + } - c.SetCookie(&http.Cookie{ - Name: "id_token", - Value: rawIDTk, - Secure: true, - SameSite: http.SameSiteLaxMode, - Path: "/", - }) - - return c.Redirect(http.StatusTemporaryRedirect, c.Request().URL.Query().Get("state")) + return rawIDTk, user, nil } +// Middleware is the HTTP middleware used for wrapping HTTP handlers registered on the echo router. +// It authorizes token (BasicAuth/token) based and cookie based sessions. func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - if o.skipper != nil && o.skipper(c) { + // It's an `Authorization` header request. + hdr := c.Response().Header().Get("Authorization") + if len(hdr) > 0 { + key, token, err := parseAuthHeader(hdr) + if err != nil { + return echo.NewHTTPError(http.StatusForbidden, err.Error()) + } + + // Validate the token. + user, ok := o.GetToken(key, token) + if !ok { + return echo.NewHTTPError(http.StatusForbidden, "invalid token:secret") + } + + // Set the user details on the handler context. + c.Set(UserKey, user) return next(c) } - rawIDTk, err := c.Cookie("id_token") - if err == nil { - // Verify the token. - idTk, err := o.verifier.Verify(c.Request().Context(), rawIDTk.Value) - if err == nil { - var user User - if err := idTk.Claims(&user); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, - fmt.Sprintf("error verifying OIDC claim: %v", user)) - } - fmt.Println(user) - c.Set(UserKey, user) - - return next(c) - } - } else if err != http.ErrNoCookie { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - // If the verification failed, redirect to the provider for auth. - nonce, err := randString(16) + // It's a cookie based session. + user, err := o.validateSession(c) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + u, _ := url.Parse(o.cfg.LoginURL) + q := url.Values{} + q.Set("next", c.Request().RequestURI) + u.RawQuery = q.Encode() + + return c.Redirect(http.StatusTemporaryRedirect, u.String()) } - c.SetCookie(&http.Cookie{ - Name: "nonce", - Value: nonce, - Secure: true, - SameSite: http.SameSiteLaxMode, - Path: "/", - }) - return c.Redirect(http.StatusTemporaryRedirect, o.cfg.AuthCodeURL(c.Request().URL.RequestURI(), oidc.Nonce(nonce))) + + // Set the user details on the handler context. + c.Set(UserKey, user) + return next(c) } } -func randString(nByte int) (string, error) { - b := make([]byte, nByte) - if _, err := io.ReadFull(rand.Reader, b); err != nil { - return "", err +// SetSession creates and sets a session (post successful login/auth). +func (o *Auth) SetSession(u models.User, oidcToken string, c echo.Context) error { + sess, err := o.sess.Acquire(c, c, nil) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "error creating session") } - return base64.RawURLEncoding.EncodeToString(b), nil + + // sess, err := simplesessions.NewSession(o.sess, c, c) + // if err != nil { + // o.log.Printf("error creating login session: %v", err) + // return echo.NewHTTPError(http.StatusInternalServerError, "error creating session") + // } + + if err := sess.SetMulti(map[string]interface{}{"user_id": u.ID, "oidc_token": oidcToken}); err != nil { + o.log.Printf("error setting login session: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, "error creating session") + } + if err := sess.Commit(); err != nil { + o.log.Printf("error committing login session: %v", err) + return echo.NewHTTPError(http.StatusInternalServerError, "error creating session") + } + + return nil +} + +func (o *Auth) validateSession(c echo.Context) (models.User, error) { + // Cookie session. + sess, err := o.sess.Acquire(c, c, nil) + if err != nil { + return models.User{}, echo.NewHTTPError(http.StatusForbidden, err.Error()) + } + + // Get the session variables. + vars, err := sess.GetMulti("user_id", "oidc_token") + if err != nil { + return models.User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + // Validate the user ID in the session. + userID, err := o.sessStore.Int(vars["user_id"], nil) + if err != nil || userID < 1 { + o.log.Printf("error fetching session user ID: %v", err) + return models.User{}, echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + } + + // If it's an OIDC session, validate the claim. + if vars["oidc_token"] != "" { + if !o.cfg.OIDC.Enabled { + return models.User{}, echo.NewHTTPError(http.StatusForbidden, "OIDC aut his not enabled.") + } + if _, err := o.verifyOIDC(vars["oidc_token"].(string), c); err != nil { + return models.User{}, err + } + } + + // Fetch user details from the database. + user, err := o.cb.GetUser(userID) + return user, err +} + +func (o *Auth) verifyOIDC(token string, c echo.Context) (models.User, error) { + idTk, err := o.verifier.Verify(c.Request().Context(), token) + if err != nil { + return models.User{}, err + } + + var user models.User + if err := idTk.Claims(&user); err != nil { + return user, echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("error verifying OIDC claim: %v", user)) + } + + if user.ID < 1 { + return user, echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("invalid user ID in OIDC: %v", user)) + } + + return user, err +} + +// parseAuthHeader parses the Authorization header and returns the api_key and access_token. +func parseAuthHeader(h string) (string, string, error) { + const authBasic = "Basic" + const authToken = "token" + + var ( + pair []string + delim = ":" + ) + + if strings.HasPrefix(h, authToken) { + // token api_key:access_token. + pair = strings.SplitN(strings.Trim(h[len(authToken):], " "), delim, 2) + } else if strings.HasPrefix(h, authBasic) { + // HTTP BasicAuth. This is supported for backwards compatibility. + payload, err := base64.StdEncoding.DecodeString(string(strings.Trim(h[len(authBasic):], " "))) + if err != nil { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "invalid Base64 value in Basic Authorization header") + } + pair = strings.SplitN(string(payload), delim, 2) + } else { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "unknown Authorization scheme") + } + + if len(pair) < 2 { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "api_key:token missing") + } + + if len(pair[0]) == 0 || len(pair[1]) == 0 { + return "", "", echo.NewHTTPError(http.StatusBadRequest, "empty `api_key` or `token`") + } + + return pair[0], pair[1], nil } diff --git a/internal/core/users.go b/internal/core/users.go index 5fd09c97..05472bac 100644 --- a/internal/core/users.go +++ b/internal/core/users.go @@ -23,7 +23,7 @@ func (c *Core) GetUsers() ([]models.User, error) { if u.Password.String != "" { u.HasPassword = true u.PasswordLogin = true - u.Password = null.String{} + // u.Password = null.String{} out[n] = u } @@ -36,18 +36,16 @@ func (c *Core) GetUsers() ([]models.User, error) { return out, nil } -// GetUser retrieves a specific user. -func (c *Core) GetUser(id int) (models.User, error) { +// GetUser retrieves a specific user based on any one given identifier. +func (c *Core) GetUser(id int, username, email string) (models.User, error) { var out models.User - if err := c.q.GetUsers.Get(&out, id); err != nil { + if err := c.q.GetUser.Get(&out, id, username, email); err != nil { return out, echo.NewHTTPError(http.StatusInternalServerError, c.i18n.Ts("globals.messages.errorFetching", "name", "{globals.terms.users}", "error", pqErrMsg(err))) } if out.Password.String != "" { out.HasPassword = true out.PasswordLogin = true - out.Password.String = "" - out.Password.Valid = false } return out, nil @@ -98,7 +96,7 @@ func (c *Core) UpdateUser(id int, u models.User) (models.User, error) { c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}")) } - return c.GetUser(id) + return c.GetUser(id, "", "") } // DeleteUsers deletes a given user. diff --git a/internal/migrations/v3.1.0.go b/internal/migrations/v3.1.0.go index eda08832..904c59b1 100644 --- a/internal/migrations/v3.1.0.go +++ b/internal/migrations/v3.1.0.go @@ -36,6 +36,13 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); + + CREATE TABLE sessions IF NOT EXISTS sessions ( + id TEXT NOT NULL PRIMARY KEY, + data jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions ON sessions (id, created_at); `); err != nil { return err } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index a6b5f40c..60c1f9ee 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -3,6 +3,9 @@ package utils import ( "crypto/rand" "net/mail" + "net/url" + "path" + "strings" ) // ValidateEmail validates whether the given string is a correctly formed e-mail address. @@ -32,3 +35,19 @@ func GenerateRandomString(n int) (string, error) { return string(bytes), nil } + +// SanitizeURI takes a URL or URI, removes the domain from it, returns only the URI. +// This is used for cleaning "next" redirect URLs/URIs to prevent open redirects. +func SanitizeURI(u string) string { + u = strings.TrimSpace(u) + if u == "" { + return "/" + } + + p, err := url.Parse(u) + if err != nil || strings.Contains(p.Path, "..") { + return "/" + } + + return path.Clean(p.Path) +} diff --git a/models/models.go b/models/models.go index 0ab7e501..88bdf70f 100644 --- a/models/models.go +++ b/models/models.go @@ -149,14 +149,18 @@ type Base struct { type User struct { Base - Username string `db:"username" json:"username"` - Password null.String `db:"password" json:"password,omitempty"` - PasswordLogin bool `db:"password_login" json:"password_login"` - Email null.String `db:"email" json:"email"` - Name string `db:"name" json:"name"` - Type string `db:"type" json:"type"` - Status string `db:"status" json:"status"` - LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"` + Username string `db:"username" json:"username"` + + // For API users, this is the plaintext API token. + Password null.String `db:"password" json:"password,omitempty"` + PasswordLogin bool `db:"password_login" json:"password_login"` + Email null.String `db:"email" json:"email"` + Name string `db:"name" json:"name"` + Type string `db:"type" json:"type"` + Status string `db:"status" json:"status"` + Avatar string `db:"-" json:"avatar"` + Permissions map[string]struct{} `db:"-" json:"-"` + LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"` HasPassword bool `db:"-" json:"-"` } diff --git a/models/queries.go b/models/queries.go index 90f6343b..fa99251d 100644 --- a/models/queries.go +++ b/models/queries.go @@ -112,6 +112,7 @@ type Queries struct { UpdateUser *sqlx.Stmt `query:"update-user"` DeleteUsers *sqlx.Stmt `query:"delete-users"` GetUsers *sqlx.Stmt `query:"get-users"` + GetUser *sqlx.Stmt `query:"get-user"` GetAPITokens *sqlx.Stmt `query:"get-api-tokens"` LoginUser *sqlx.Stmt `query:"login-user"` } diff --git a/queries.sql b/queries.sql index 02f1dd79..9e060c25 100644 --- a/queries.sql +++ b/queries.sql @@ -1058,6 +1058,16 @@ WITH u AS ( ) DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0; +-- name: get-user +SELECT * FROM users WHERE + ( + CASE + WHEN $1::INT != 0 THEN id = $1 + WHEN $2::TEXT != '' THEN username = $2 + WHEN $3::TEXT != '' THEN email = $3 + END + ) AND status='enabled'; + -- name: get-users SELECT * FROM users WHERE $1=0 OR id=$1 ORDER BY created_at; diff --git a/schema.sql b/schema.sql index d06c7d58..0279f740 100644 --- a/schema.sql +++ b/schema.sql @@ -316,6 +316,14 @@ CREATE TABLE users ( updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); +-- user sessions +CREATE TABLE sessions ( + id TEXT NOT NULL PRIMARY KEY, + data jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL +); +CREATE INDEX idx_sessions ON sessions (id, created_at); + -- materialized views -- dashboard stats diff --git a/static/public/static/style.css b/static/public/static/style.css index 905b79d0..8808c4b5 100644 --- a/static/public/static/style.css +++ b/static/public/static/style.css @@ -34,7 +34,7 @@ h4 { margin-bottom: 45px; } -input[type="text"], input[type="email"], select { +input[type="text"], input[type="email"], input[type="password"], select { padding: 10px 15px; border: 1px solid #888; border-radius: 3px; @@ -61,6 +61,9 @@ input[disabled] { .right { text-align: right; } +.error { + color: #FF5722; +} .button { background: #0055d4; padding: 15px 30px; diff --git a/static/public/templates/login.html b/static/public/templates/login.html new file mode 100644 index 00000000..596ca2f3 --- /dev/null +++ b/static/public/templates/login.html @@ -0,0 +1,39 @@ +{{ define "admin-login" }} +{{ template "header" .}} + +
+

{{ .L.T "users.login"}}

+ {{ if .Data.PasswordEnabled }} +
+
+ + +

+ + +

+

+ + +

+ + {{ if .Data.Error }}

{{ .Data.Error }}

{{ end }} + +

+
+
+ {{ end }} + + {{ if .Data.OIDCEnabled }} +
+
+ +

+
+
+ {{ end }} + +
+ +{{ template "footer" .}} +{{ end }} \ No newline at end of file