EagleCast
publish-github-pages / deploy (push) Waiting to run

This commit is contained in:
h202-wq
2026-07-09 10:03:32 -04:00
commit 66d9a033c9
446 changed files with 162542 additions and 0 deletions
+472
View File
@@ -0,0 +1,472 @@
package auth
import (
"context"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/labstack/echo/v4"
"github.com/zerodha/simplesessions/stores/postgres/v3"
"github.com/zerodha/simplesessions/v3"
"golang.org/x/oauth2"
)
type OIDCclaim struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Sub string `json:"sub"`
Picture string `json:"picture"`
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
type OIDCConfig struct {
Enabled bool `json:"enabled"`
ProviderURL string `json:"provider_url"`
RedirectURL string `json:"redirect_url"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AutoCreateUsers bool `json:"auto_create_users"`
DefaultUserRoleID int `json:"default_user_role_id"`
DefaultListRoleID int `json:"default_list_role_id"`
}
type BasicAuthConfig struct {
Enabled bool `json:"enabled"`
Username string `json:"username"`
Password string `json:"password"`
}
type Config struct {
OIDC OIDCConfig
BasicAuth BasicAuthConfig
}
// Callbacks takes two callback functions required by simplesessions.
type Callbacks struct {
SetCookie func(cookie *http.Cookie, w any) error
GetCookie func(name string, r any) (*http.Cookie, error)
GetUser func(id int) (User, error)
}
type Auth struct {
apiUsers map[string]User
sync.RWMutex
cfg Config
oauthCfg oauth2.Config
verifier *oidc.IDTokenVerifier
provider *oidc.Provider
sess *simplesessions.Manager
sessStore *postgres.Store
cb *Callbacks
log *log.Logger
}
var sessPruneInterval = time.Hour * 12
// New returns an initialize Auth instance.
func New(cfg Config, db *sql.DB, cb *Callbacks, lo *log.Logger) (*Auth, error) {
a := &Auth{
cfg: cfg,
cb: cb,
log: lo,
apiUsers: map[string]User{},
}
// Initialize session manager.
a.sess = simplesessions.New(simplesessions.Options{
EnableAutoCreate: false,
SessionIDLength: 64,
Cookie: simplesessions.CookieOptions{
IsHTTPOnly: true,
MaxAge: time.Hour * 24 * 7,
},
})
st, err := postgres.New(postgres.Opt{}, db)
if err != nil {
return nil, err
}
a.sessStore = st
a.sess.UseStore(st)
a.sess.SetCookieHooks(cb.GetCookie, cb.SetCookie)
// 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(sessPruneInterval)
}()
return a, nil
}
// CacheAPIUsers caches API users for authenticating requests. It wipes
// the existing cache every time and is meant for syncing all API users
// in the database in one shot.
func (o *Auth) CacheAPIUsers(users []User) {
o.Lock()
defer o.Unlock()
o.apiUsers = map[string]User{}
for _, u := range users {
o.apiUsers[u.Username] = u
}
}
// CacheAPIUser caches an API user for authenticating requests.
func (o *Auth) CacheAPIUser(u User) {
o.Lock()
o.apiUsers[u.Username] = u
o.Unlock()
}
// GetAPIToken validates an API user+token.
func (o *Auth) GetAPIToken(user string, token string) (User, bool) {
o.RLock()
t, ok := o.apiUsers[user]
o.RUnlock()
if !ok || subtle.ConstantTimeCompare([]byte(t.Password.String), []byte(HashAPIToken(token))) != 1 {
return User{}, false
}
return t, true
}
// initOIDC initializes the OIDC provider, verifier, and OAuth config.
func (o *Auth) initOIDC() error {
if !o.cfg.OIDC.Enabled {
return fmt.Errorf("OIDC is not enabled")
}
provider, err := oidc.NewProvider(context.Background(), o.cfg.OIDC.ProviderURL)
if err != nil {
return fmt.Errorf("error initializing OIDC OAuth provider: %v", err)
}
o.verifier = provider.Verifier(&oidc.Config{
ClientID: o.cfg.OIDC.ClientID,
})
o.oauthCfg = oauth2.Config{
ClientID: o.cfg.OIDC.ClientID,
ClientSecret: o.cfg.OIDC.ClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: o.cfg.OIDC.RedirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
o.provider = provider
return nil
}
// getProvider returns the OIDC provider, initializing it if necessary.
func (o *Auth) getProvider() (*oidc.Provider, error) {
o.Lock()
defer o.Unlock()
if o.provider == nil {
if err := o.initOIDC(); err != nil {
return nil, err
}
}
return o.provider, nil
}
// getVerifier returns the OIDC verifier, initializing it if necessary.
func (o *Auth) getVerifier() (*oidc.IDTokenVerifier, error) {
o.Lock()
defer o.Unlock()
if o.verifier == nil {
if err := o.initOIDC(); err != nil {
return nil, err
}
}
return o.verifier, nil
}
// getOAuthConfig returns the OAuth config, initializing it if necessary.
func (o *Auth) getOAuthConfig() (*oauth2.Config, error) {
o.Lock()
defer o.Unlock()
if o.oauthCfg.ClientID == "" {
if err := o.initOIDC(); err != nil {
return nil, err
}
}
return &o.oauthCfg, nil
}
// GetOIDCAuthURL returns the OIDC provider's auth URL to redirect to.
func (o *Auth) GetOIDCAuthURL(state, nonce string) string {
cfg, err := o.getOAuthConfig()
if err != nil {
o.log.Printf("error getting OAuth config: %v", err)
return ""
}
return cfg.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, OIDCclaim, error) {
cfg, err := o.getOAuthConfig()
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error getting OAuth config: %v", err))
}
tk, err := cfg.Exchange(context.TODO(), code)
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error exchanging token: %v", err))
}
rawIDTk, ok := tk.Extra("id_token").(string)
if !ok {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "`id_token` missing.")
}
verifier, err := o.getVerifier()
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error getting verifier: %v", err))
}
idTk, err := verifier.Verify(context.TODO(), rawIDTk)
if err != nil {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("error verifying ID token: %v", err))
}
if idTk.Nonce != nonce {
return "", OIDCclaim{}, echo.NewHTTPError(http.StatusUnauthorized, "nonce did not match")
}
var claims OIDCclaim
if err := idTk.Claims(&claims); err != nil {
return "", OIDCclaim{}, errors.New("error getting user from OIDC")
}
// If claims doesn't have the e-mail, attempt to fetch it from the userinfo endpoint.
if claims.Email == "" {
provider, err := o.getProvider()
if err != nil {
return "", OIDCclaim{}, fmt.Errorf("error getting provider: %v", err)
}
userInfo, err := provider.UserInfo(context.TODO(), oauth2.StaticTokenSource(tk))
if err != nil {
return "", OIDCclaim{}, errors.New("error fetching user info from OIDC")
}
// Parse the UserInfo claims into the claims struct
if err := userInfo.Claims(&claims); err != nil {
return "", OIDCclaim{}, errors.New("error parsing user info claims")
}
}
return rawIDTk, claims, 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 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.
hdr := strings.TrimSpace(c.Request().Header.Get("Authorization"))
// If cookie is set, ignore BasicAuth. This is to preserve backwards compatibility
// in v3 -> v4 upgrade where the user browser sessions would still have old
// BasicAuth credentials, which no longer work in the new system which expects
// session cookies instead, which causes a redirect loop despite loggin in and session
// cookies being set.
//
// TODO: This should be removed in a future version.
if c := strings.TrimSpace(c.Request().Header.Get("Cookie")); strings.Contains(c, "session=") {
hdr = ""
}
if len(hdr) > 0 {
key, token, err := parseAuthHeader(hdr)
if err != nil {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, err.Error()))
return next(c)
}
// Validate the token.
user, ok := o.GetAPIToken(key, token)
if !ok {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid API credentials"))
return next(c)
}
// Set the user details on the handler context.
c.Set(UserHTTPCtxKey, user)
return next(c)
}
// Is it a cookie based session?
sess, user, err := o.validateSession(c)
if err != nil {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
return next(c)
}
// Set the user details on the handler context.
c.Set(UserHTTPCtxKey, user)
c.Set(SessionKey, sess)
return next(c)
}
}
// Perm is an HTTP handler middleware that checks if the authenticated user has the required permissions.
func (o *Auth) Perm(next echo.HandlerFunc, perms ...string) echo.HandlerFunc {
return func(c echo.Context) error {
u, ok := c.Get(UserHTTPCtxKey).(User)
if !ok {
c.Set(UserHTTPCtxKey, echo.NewHTTPError(http.StatusForbidden, "invalid session"))
return next(c)
}
// If the current user is a Super Admin user, do no checks.
if u.UserRole.ID == SuperAdminRoleID {
return next(c)
}
// Check if the current handler's permission is in the user's permission map.
var (
has = false
perm = ""
)
for _, perm = range perms {
if _, ok := u.PermissionsMap[perm]; ok {
has = true
break
}
}
if !has {
return echo.NewHTTPError(http.StatusForbidden, fmt.Sprintf("permission denied: %s", perm))
}
return next(c)
}
}
// SaveSession creates and sets a session (post successful login/auth).
func (o *Auth) SaveSession(u User, oidcToken string, c echo.Context) error {
sess, err := o.sess.NewSession(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]any{"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")
}
return nil
}
// GetSessionID returns the current session ID from the echo context.
func GetSessionID(c echo.Context) string {
sess, ok := c.Get(SessionKey).(*simplesessions.Session)
if !ok || sess == nil {
return ""
}
return sess.ID()
}
// HashAPIToken returns the SHA-256 hex digest of an API token.
func HashAPIToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// validateSession checks if the cookie session is valid (in the DB) and returns the session and user details.
func (o *Auth) validateSession(c echo.Context) (*simplesessions.Session, User, error) {
// Cookie session.
sess, err := o.sess.Acquire(context.TODO(), c, c)
if err != nil {
return nil, User{}, echo.NewHTTPError(http.StatusForbidden, err.Error())
}
// Get the session variables.
vars, err := sess.GetMulti("user_id", "oidc_token")
if err != nil {
return nil, 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 nil, User{}, echo.NewHTTPError(http.StatusInternalServerError, "invalid session.")
}
// Fetch user details from the database.
user, err := o.cb.GetUser(userID)
if err != nil {
o.log.Printf("error fetching session user: %v", err)
}
return sess, user, err
}
// GetUser retrieves and returns the User object from an authenticated
// HTTP handler request.
func GetUser(c echo.Context) User {
return c.Get(UserHTTPCtxKey).(User)
}
// 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
}
+330
View File
@@ -0,0 +1,330 @@
package auth
import (
"encoding/json"
"net/http"
"github.com/labstack/echo/v4"
"github.com/lib/pq"
null "gopkg.in/volatiletech/null.v6"
)
var ErrPermDenied = echo.NewHTTPError(http.StatusForbidden, "permission denied")
// PermType indicates a generic permission type which is either get (read) or manage (write).
type PermType uint8
const (
PermTypeGet PermType = 1 << iota
PermTypeManage
)
const (
// UserHTTPCtxKey is the key on which the User profile is set on echo handlers.
UserHTTPCtxKey = "auth_user"
SessionKey = "auth_session"
)
const (
// SuperAdminRoleID is the database ID of the primordial super admin role.
SuperAdminRoleID = 1
// User.
UserTypeUser = "user"
UserTypeAPI = "api"
UserStatusEnabled = "enabled"
UserStatusDisabled = "disabled"
// Role.
RoleTypeUser = "user"
RoleTypeList = "list"
)
// List of all granular permissions.
const (
PermListGetAll = "lists:get_all"
PermListManageAll = "lists:manage_all"
PermListManage = "list:manage"
PermListGet = "list:get"
PermSubscribersGet = "subscribers:get"
PermSubscribersGetAll = "subscribers:get_all"
PermSubscribersManage = "subscribers:manage"
PermSubscribersImport = "subscribers:import"
PermSubscribersSqlQuery = "subscribers:sql_query"
PermTxSend = "tx:send"
PermCampaignsGet = "campaigns:get"
PermCampaignsGetAll = "campaigns:get_all"
PermCampaignsGetAnalytics = "campaigns:get_analytics"
PermCampaignsManage = "campaigns:manage"
PermCampaignsManageAll = "campaigns:manage_all"
PermCampaignsSend = "campaigns:send"
PermBouncesGet = "bounces:get"
PermBouncesManage = "bounces:manage"
PermWebhooksPostBounce = "webhooks:post_bounce"
PermMediaGet = "media:get"
PermMediaManage = "media:manage"
PermTemplatesGet = "templates:get"
PermTemplatesManage = "templates:manage"
PermUsersGet = "users:get"
PermUsersManage = "users:manage"
PermRolesGet = "roles:get"
PermRolesManage = "roles:manage"
PermSettingsGet = "settings:get"
PermSettingsManage = "settings:manage"
PermSettingsMaintain = "settings:maintain"
)
// Base holds common fields shared across models.
type Base struct {
ID int `db:"id" json:"id"`
CreatedAt null.Time `db:"created_at" json:"created_at"`
UpdatedAt null.Time `db:"updated_at" json:"updated_at"`
}
// User represents an admin user.
type User struct {
Base
Username string `db:"username" json:"username"`
// For API users, this stores the SHA-256 token hash.
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 null.String `db:"avatar" json:"avatar"`
TwofaType string `db:"twofa_type" json:"twofa_type"`
TwofaKey null.String `db:"twofa_key" json:"-"`
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
UserRoleID int `db:"user_role_id" json:"user_role_id,omitempty"`
UserRoleName string `db:"user_role_name" json:"-"`
ListRoleID *int `db:"list_role_id" json:"list_role_id,omitempty"`
ListRoleName null.String `db:"list_role_name" json:"-"`
UserRolePerms pq.StringArray `db:"user_role_permissions" json:"-"`
ListsPermsRaw *json.RawMessage `db:"list_role_perms" json:"-"`
// Non-DB fields filled post-retrieval.
UserRole struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Permissions []string `db:"-" json:"permissions"`
} `db:"-" json:"user_role"`
ListRole *ListRolePermissions `db:"-" json:"list_role"`
PermissionsMap map[string]struct{} `db:"-" json:"-"`
ListPermissionsMap map[int]map[string]struct{} `db:"-" json:"-"`
GetListIDs []int `db:"-" json:"-"`
ManageListIDs []int `db:"-" json:"-"`
HasPassword bool `db:"-" json:"-"`
}
type ListPermission struct {
ID int `json:"id"`
Name string `json:"name"`
Permissions pq.StringArray `json:"permissions"`
}
type ListRolePermissions struct {
ID int `db:"-" json:"id"`
Name string `db:"-" json:"name"`
Lists []ListPermission `db:"-" json:"lists"`
}
type Role struct {
Base
Type string `db:"type" json:"type"`
Name null.String `db:"name" json:"name"`
Permissions pq.StringArray `db:"permissions" json:"permissions"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
type ListRole struct {
Base
Name null.String `db:"name" json:"name"`
ListID null.Int `db:"list_id" json:"-"`
ParentID null.Int `db:"parent_id" json:"-"`
ListsRaw json.RawMessage `db:"list_permissions" json:"-"`
Lists []ListPermission `db:"-" json:"lists"`
}
// HasPerm checks if the user has a specific permission.
func (u *User) HasPerm(perm string) bool {
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true
}
_, ok := u.PermissionsMap[perm]
return ok
}
// HasListPerm checks if the user has get or manage access to the given list.
// perm is either PermGet or PermManage.
func (u *User) HasListPerm(types PermType, listIDs ...int) error {
var permAll, perm string
if types == 0 {
return ErrPermDenied
}
if types&PermTypeGet != 0 {
permAll = PermListGetAll
perm = PermListGet
} else if types&PermTypeManage != 0 {
permAll = PermListManageAll
perm = PermListManage
}
// Check if the user has permissions for all lists or the specific list.
if u.HasPerm(permAll) {
return nil
}
for _, id := range listIDs {
if id > 0 {
if !u.hasListPerm(perm, id) {
return ErrPermDenied
}
}
}
return nil
}
func (u *User) hasListPerm(perm string, listID int) bool {
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true
}
if _, ok := u.ListPermissionsMap[listID]; !ok {
return false
}
_, ok := u.ListPermissionsMap[listID][perm]
return ok
}
// GetPermittedLists returns a list of IDs the user has access to based on
// the given get / manage permissions. If the user has the blanket "*_all"
// permission (or the user is a super admin), then the bool is set to true and
// the list is nil as all lists are permitted.
func (u *User) GetPermittedLists(types PermType) (bool, []int) {
if types == 0 {
return false, nil
}
// Short-circuit if the user is the primordial super admin.
if u.UserRoleID == SuperAdminRoleID {
return true, nil
}
var (
get = types&PermTypeGet != 0
manage = types&PermTypeManage != 0
)
// If the user has the list:get_all or list:manage_all permission, no
// further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return true, nil
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return true, nil
}
}
if get {
// If the user has per-list permissions, return that. Otherwise, let the
// 'manage' permission check run.
if len(u.GetListIDs) > 0 {
out := make([]int, len(u.GetListIDs))
copy(out, u.GetListIDs)
return false, out
}
}
if manage {
// User has per-list permissions.
out := make([]int, len(u.ManageListIDs))
copy(out, u.ManageListIDs)
return false, out
}
return false, nil
}
// FilterListsByPerm returns list IDs filtered by either of the given perms.
func (u *User) FilterListsByPerm(types PermType, listIDs []int) []int {
if types == 0 {
return nil
}
var (
get = types&PermTypeGet != 0
manage = types&PermTypeManage != 0
)
// If the user has full list management permission,
// no further checks are required.
if get {
if _, ok := u.PermissionsMap[PermListGetAll]; ok {
return listIDs
}
}
if manage {
if _, ok := u.PermissionsMap[PermListManageAll]; ok {
return listIDs
}
}
out := make([]int, 0, len(listIDs))
for _, id := range listIDs {
// Check if it exists in the map.
if l, ok := u.ListPermissionsMap[id]; ok {
// Check if any of the given permission exists for it.
if get {
if _, ok := l[PermListGet]; ok {
out = append(out, id)
}
} else if manage {
if _, ok := l[PermListManage]; ok {
out = append(out, id)
}
}
}
}
return out
}
// GetPermittedListIDs filters the given list IDs by the user's get/manage
// permissions and returns the filtered set. Unlike `FilterListsByPerm()`, if no
// IDs are present (empty input or 0 permitted lists), it falls back to the user's
// permitted list IDs if any. This is useful for endpoints which accept a few IDs
// or the lack of which implies "all".
func (u *User) GetPermittedListIDs(listIDs []int) []int {
listIDs = u.FilterListsByPerm(PermTypeGet|PermTypeManage, listIDs)
if len(listIDs) == 0 {
if _, ok := u.PermissionsMap[PermSubscribersGetAll]; !ok {
if len(u.GetListIDs) > 0 {
return u.GetListIDs
}
return []int{-1}
}
}
return listIDs
}