Refactor handler groups and add mising auth features like logout.

This commit is contained in:
Kailash Nadh 2024-05-26 00:03:41 +05:30
parent a4e8c1daea
commit 31c5358d0e
13 changed files with 116 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,9 +12,22 @@
<template #end>
<navigation v-if="isMobile" :is-mobile="isMobile" :active-item="activeItem" :active-group="activeGroup"
@toggleGroup="toggleGroup" @doLogout="doLogout" />
<b-navbar-item v-else tag="div">
<a href="#" @click.prevent="doLogout">{{ $t('users.logout') }}</a>
</b-navbar-item>
<b-navbar-dropdown v-else>
<template #label>
<div class="avatar">
<img v-if="profile.avatar" src="profile.avatar" alt="" />
<span v-else>{{ profile.username[0].toUpperCase() }}</span>
</div>
{{ profile.username }}
</template>
<b-navbar-item href="#">
<a href="#" @click.prevent="doLogout"><b-icon icon="account-outline" /> {{ $t('users.account') }}</a>
</b-navbar-item>
<b-navbar-item href="#">
<a href="#" @click.prevent="doLogout"><b-icon icon="logout-variant" /> {{ $t('users.logout') }}</a>
</b-navbar-item>
</b-navbar-dropdown>
</template>
</b-navbar>
@ -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;
});
},
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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