Add public login page and auth middleware and handlers.

This commit is contained in:
Kailash Nadh 2024-05-23 11:54:10 +05:30
parent c2bd15a641
commit a4e8c1daea
17 changed files with 674 additions and 162 deletions

176
cmd/auth.go Normal file
View file

@ -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")))
}

View file

@ -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
}

View file

@ -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)

View file

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

5
go.mod
View file

@ -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

71
go.sum
View file

@ -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=

View file

@ -595,6 +595,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",
@ -607,7 +608,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",

View file

@ -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
}

View file

@ -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.

View file

@ -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
}

View file

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

View file

@ -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:"-"`
}

View file

@ -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"`
}

View file

@ -1057,6 +1057,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;

View file

@ -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

View file

@ -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;

View file

@ -0,0 +1,39 @@
{{ define "admin-login" }}
{{ template "header" .}}
<section>
<h2>{{ .L.T "users.login"}}</h2>
{{ if .Data.PasswordEnabled }}
<form method="post" action="" class="form">
<div>
<input type="hidden" name="nonce" value="{{ .Data.Nonce }}" />
<input type="hidden" name="next" value="{{ .Data.NextURI }}" />
<p>
<label for="username">{{ .L.T "users.username" }}</label>
<input id="username" type="text" name="username" autofocus required minlength="3" />
</p>
<p>
<label for="password">{{ .L.T "users.password" }}</label>
<input id="password" type="password" name="password" required minlength="8" />
</p>
{{ if .Data.Error }}<p><span class="error">{{ .Data.Error }}</span></p>{{ end }}
<p><button class="button" type="submit">{{ .L.T "users.login" }}</button></p>
</div>
</form>
{{ end }}
{{ if .Data.OIDCEnabled }}
<form method="post" action="/oidc/login">
<div>
<input type="hidden" name="nonce" value="{{ .Data.Nonce }}" />
<p><button type="submit">{{ .L.T "users.loginOIDC" }}</button></p>
</div>
</form>
{{ end }}
</section>
{{ template "footer" .}}
{{ end }}