mirror of
https://github.com/knadh/listmonk.git
synced 2024-09-20 07:16:33 +08:00
Refactor handler groups and add mising auth features like logout.
This commit is contained in:
parent
a4e8c1daea
commit
31c5358d0e
12
cmd/auth.go
12
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.
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
12
cmd/users.go
12
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})
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
3
frontend/vite.config.js
vendored
3
frontend/vite.config.js
vendored
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Loading…
Reference in a new issue