mirror of
https://github.com/knadh/listmonk.git
synced 2024-11-13 02:55:04 +08:00
Add public login page and auth middleware and handlers.
This commit is contained in:
parent
1516bf216f
commit
57ac9dca4b
17 changed files with 674 additions and 162 deletions
176
cmd/auth.go
Normal file
176
cmd/auth.go
Normal 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")))
|
||||
}
|
47
cmd/init.go
47
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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
56
cmd/users.go
56
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})
|
||||
}
|
||||
|
|
5
go.mod
5
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
|
||||
|
|
71
go.sum
71
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=
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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:"-"`
|
||||
}
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
10
queries.sql
10
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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
39
static/public/templates/login.html
Normal file
39
static/public/templates/login.html
Normal 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 }}
|
Loading…
Reference in a new issue