From 31c5358d0e4795178a42807f40203fb16f84c276 Mon Sep 17 00:00:00 2001 From: Kailash Nadh Date: Sun, 26 May 2024 00:03:41 +0530 Subject: [PATCH] Refactor handler groups and add mising auth features like logout. --- cmd/auth.go | 12 +++++----- cmd/init.go | 7 +++--- cmd/install.go | 12 ++++++++++ cmd/users.go | 12 ++++++++++ config.toml.sample | 9 +++---- docker-compose.yml | 2 +- frontend/src/App.vue | 35 +++++++++++++++++---------- frontend/src/api/index.js | 9 ++++--- frontend/src/assets/style.scss | 20 ++++++++++++++++ frontend/vite.config.js | 3 +++ i18n/en.json | 1 + internal/auth/auth.go | 44 ++++++++++++++++++---------------- schema.sql | 3 ++- 13 files changed, 116 insertions(+), 53 deletions(-) diff --git a/cmd/auth.go b/cmd/auth.go index e21839de..85db38dc 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -5,9 +5,10 @@ import ( "strings" "time" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/utils" "github.com/labstack/echo/v4" - "github.com/vividvilla/simplesessions" + "github.com/vividvilla/simplesessions/v2" ) type loginTpl struct { @@ -71,17 +72,16 @@ func handleLoginPage(c echo.Context) error { return c.Render(http.StatusOK, "admin-login", out) } -// handleLogoutPage logs a user out. -func handleLogoutPage(c echo.Context) error { +// handleLogout logs a user out. +func handleLogout(c echo.Context) error { var ( - app = c.Get("app").(*App) - sess = c.Get("session").(*simplesessions.Session) + sess = c.Get(auth.SessionKey).(*simplesessions.Session) ) // Clear the session. _ = sess.Clear() - return c.Redirect(http.StatusFound, app.constants.RootURL) + return c.JSON(http.StatusOK, okResp{true}) } // doLogin logs a user in with a username and password. diff --git a/cmd/init.go b/cmd/init.go index 3620e9fc..389f6a3d 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -59,6 +59,7 @@ type constants struct { RootURL string `koanf:"root_url"` LogoURL string `koanf:"logo_url"` FaviconURL string `koanf:"favicon_url"` + LoginURL string `koanf:"login_url"` FromEmail string `koanf:"from_email"` NotifyEmails []string `koanf:"notify_emails"` EnablePublicSubPage bool `koanf:"enable_public_subscription_page"` @@ -89,8 +90,6 @@ type constants struct { CaptchaKey string `koanf:"captcha_key"` CaptchaSecret string `koanf:"captcha_secret"` } `koanf:"security"` - AdminUsername []byte `koanf:"admin_username"` - AdminPassword []byte `koanf:"admin_password"` Appearance struct { AdminCSS []byte `koanf:"admin.custom_css"` @@ -397,6 +396,7 @@ func initConstants() *constants { } c.RootURL = strings.TrimRight(c.RootURL, "/") + c.LoginURL = path.Join(uriAdmin, "/login") c.Lang = ko.String("app.lang") c.Privacy.Exportable = maps.StringSliceToLookupMap(ko.Strings("privacy.exportable")) c.MediaUpload.Provider = ko.String("upload.provider") @@ -943,8 +943,7 @@ func initAuth(db *sql.DB, ko *koanf.Koanf, co *core.Core) *auth.Auth { } a, err := auth.New(auth.Config{ - OIDC: oidcCfg, - LoginURL: path.Join(uriAdmin, "/login"), + OIDC: oidcCfg, }, db, cb, lo) if err != nil { lo.Fatalf("error initializing auth: %v", err) diff --git a/cmd/install.go b/cmd/install.go index 24c4527c..6ab24acb 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -62,6 +62,18 @@ func install(lastVer string, db *sqlx.DB, fs stuffbin.FileSystem, prompt, idempo // Load the queries. q := prepareQueries(qMap, db, ko) + // Create super admin. + var ( + user = ko.String("app.admin_username") + password = ko.String("app.admin_password") + ) + if len(user) < 2 || len(password) < 8 { + lo.Fatal("admin_username should be min 3 chars and admin_password should be min 8 chars") + } + if _, err := q.CreateUser.Exec(user, true, password, user+"@listmonk", user, "super", "enabled"); err != nil { + lo.Fatalf("error creating superadmin user: %v", err) + } + // Sample list. var ( defList int diff --git a/cmd/users.go b/cmd/users.go index 308400c2..a6124948 100644 --- a/cmd/users.go +++ b/cmd/users.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/knadh/listmonk/internal/auth" "github.com/knadh/listmonk/internal/utils" "github.com/knadh/listmonk/models" "github.com/labstack/echo/v4" @@ -198,3 +199,14 @@ func handleDeleteUsers(c echo.Context) error { return c.JSON(http.StatusOK, okResp{true}) } + +// handleGetUserProfile fetches the uesr profile for the currently logged in user. +func handleGetUserProfile(c echo.Context) error { + var ( + user = c.Get(auth.UserKey).(models.User) + ) + user.Password.String = "" + user.Password.Valid = false + + return c.JSON(http.StatusOK, okResp{user}) +} diff --git a/config.toml.sample b/config.toml.sample index 89e0f099..d58b56c8 100644 --- a/config.toml.sample +++ b/config.toml.sample @@ -5,10 +5,11 @@ # port, use port 80 (this will require running with elevated permissions). address = "localhost:9000" -# BasicAuth authentication for the admin dashboard. This will eventually -# be replaced with a better multi-user, role-based authentication system. -# IMPORTANT: Leave both values empty to disable authentication on admin -# only where an external authentication is already setup. +# IMPORTANT: This is only used during installation (--install) for creating +# the superadmin user in the database. +# +# After installation, login to the admin dashboard, update the superadmin's +# user profile, and empty these two variables from the config file. admin_username = "listmonk" admin_password = "listmonk" diff --git a/docker-compose.yml b/docker-compose.yml index e15ea101..d72db2ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ x-app-defaults: &app-defaults - TZ=Etc/UTC x-db-defaults: &db-defaults - image: postgres:13-alpine + image: postgres:14-alpine ports: - "9432:5432" networks: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index e03134fb..b4f15f38 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -12,9 +12,22 @@ @@ -72,6 +85,7 @@ export default Vue.extend({ data() { return { + profile: {}, activeItem: {}, activeGroup: {}, windowWidth: window.innerWidth, @@ -114,17 +128,9 @@ export default Vue.extend({ }, doLogout() { - const http = new XMLHttpRequest(); - - const u = uris.root.substr(-1) === '/' ? uris.root : `${uris.root}/`; - http.open('get', `${u}api/logout`, false, 'logout_non_user', 'logout_non_user'); - http.onload = () => { + this.$api.logout().then(() => { document.location.href = uris.root; - }; - http.onerror = () => { - document.location.href = uris.root; - }; - http.send(); + }); }, listenEvents() { @@ -168,6 +174,9 @@ export default Vue.extend({ }); this.listenEvents(); + this.$api.getUserProfile().then((d) => { + this.profile = d; + }); }, }); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 6771dbb6..c7467444 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -422,9 +422,7 @@ export const getLang = async (lang) => http.get( { loading: models.lang, camelCase: false }, ); -export const logout = async () => http.get('/api/logout', { - auth: { username: 'wrong', password: 'wrong' }, -}); +export const logout = async () => http.post('/api/logout'); export const deleteGCCampaignAnalytics = async (typ, beforeDate) => http.delete( `/api/maintenance/analytics/${typ}`, @@ -479,3 +477,8 @@ export const deleteUser = (id) => http.delete( `/api/users/${id}`, { loading: models.users }, ); + +export const getUserProfile = () => http.get( + '/api/profile', + { loading: models.users }, +); diff --git a/frontend/src/assets/style.scss b/frontend/src/assets/style.scss index 737736a0..399c4202 100644 --- a/frontend/src/assets/style.scss +++ b/frontend/src/assets/style.scss @@ -172,6 +172,26 @@ section { .navbar { box-shadow: 0 0 3px $grey-lighter; + + .avatar { + img { + display: inline-block; + border-radius: 100%; + width: 24px; + height: 24px; + } + span { + background-color: #ddd; + border-radius: 100%; + width: 24px; + height: 24px; + text-align: center; + display: inline-block; + margin-right: 10px; + font-size: 0.875rem; + line-height: 1.6; + } + } } .navbar-brand { padding: 0 0 0 25px; diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 730056a0..4a45f7e5 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -28,6 +28,9 @@ export default defineConfig(({ _, mode }) => { '^/(api|webhooks|subscription|public|health)': { target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000', }, + '^/admin/login': { + target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000', + }, '^/(admin\/custom\.(css|js))': { target: env.LISTMONK_API_URL || 'http://127.0.0.1:9000', }, diff --git a/i18n/en.json b/i18n/en.json index a364ba2f..532eec16 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -597,6 +597,7 @@ "users.login": "Login", "users.loginOIDC": "Login with OIDC", "users.logout": "Logout", + "users.account": "Account", "users.lastLogin": "Last login", "users.newUser": "New user", "users.type": "Type", diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 20798357..0496c05b 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -9,7 +9,6 @@ import ( "fmt" "log" "net/http" - "net/url" "strings" "sync" "time" @@ -23,8 +22,11 @@ import ( "golang.org/x/oauth2" ) -// UserKey is the key on which the User profile is set on echo handlers. -const UserKey = "auth_user" +const ( + // UserKey is the key on which the User profile is set on echo handlers. + UserKey = "auth_user" + SessionKey = "auth_session" +) const ( sessTypeNative = "native" @@ -51,7 +53,6 @@ type BasicAuthConfig struct { type Config struct { OIDC OIDCConfig BasicAuth BasicAuthConfig - LoginURL string } // Callbacks takes two callback functions required by simplesessions. @@ -190,7 +191,9 @@ func (o *Auth) ExchangeOIDCToken(code, nonce string) (string, models.User, error } // 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. +// It authorizes token (BasicAuth/token) based and cookie based sessions and on successful auth, +// sets the authenticated User{} on the echo context on the key UserKey. On failure, it sets an Error{} +// instead on the same key. func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { // It's an `Authorization` header request. @@ -198,13 +201,15 @@ func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc { if len(hdr) > 0 { key, token, err := parseAuthHeader(hdr) if err != nil { - return echo.NewHTTPError(http.StatusForbidden, err.Error()) + c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, err.Error())) + return next(c) } // Validate the token. user, ok := o.GetToken(key, token) if !ok { - return echo.NewHTTPError(http.StatusForbidden, "invalid token:secret") + c.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid token:secret")) + return next(c) } // Set the user details on the handler context. @@ -213,18 +218,15 @@ func (o *Auth) Middleware(next echo.HandlerFunc) echo.HandlerFunc { } // It's a cookie based session. - user, err := o.validateSession(c) + sess, user, err := o.validateSession(c) if err != nil { - 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.Set(UserKey, echo.NewHTTPError(http.StatusForbidden, "invalid session")) + return next(c) } // Set the user details on the handler context. c.Set(UserKey, user) + c.Set(SessionKey, sess) return next(c) } } @@ -254,39 +256,39 @@ func (o *Auth) SetSession(u models.User, oidcToken string, c echo.Context) error return nil } -func (o *Auth) validateSession(c echo.Context) (models.User, error) { +func (o *Auth) validateSession(c echo.Context) (*simplesessions.Session, 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()) + return nil, 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()) + return nil, 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()) + return nil, 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.") + return nil, 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 + return nil, models.User{}, err } } // Fetch user details from the database. user, err := o.cb.GetUser(userID) - return user, err + return sess, user, err } func (o *Auth) verifyOIDC(token string, c echo.Context) (models.User, error) { diff --git a/schema.sql b/schema.sql index 0279f740..c930b344 100644 --- a/schema.sql +++ b/schema.sql @@ -317,12 +317,13 @@ CREATE TABLE users ( ); -- user sessions +DROP TABLE IF EXISTS sessions CASCADE; 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); +DROP INDEX IF EXISTS idx_sessions; CREATE INDEX idx_sessions ON sessions (id, created_at); -- materialized views