mirror of
https://github.com/Theodor-Springmann-Stiftung/musenalm.git
synced 2025-10-28 16:55:32 +00:00
user login & middleware complete
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/views"
|
||||
@@ -168,6 +169,9 @@ func (app *App) setWatchers(engine *templating.Engine) {
|
||||
func (app *App) bindPages(engine *templating.Engine) ServeFunc {
|
||||
return func(e *core.ServeEvent) error {
|
||||
e.Router.GET("/assets/{path...}", apis.Static(views.StaticFS, true))
|
||||
// INFO: Global middleware to get the authenticated user:
|
||||
e.Router.BindFunc(middleware.Authenticated(e.App))
|
||||
|
||||
// INFO: we put this here, to make sure all migrations are done
|
||||
for _, page := range pages {
|
||||
err := page.Up(e.App, engine)
|
||||
|
||||
@@ -503,6 +503,7 @@ const (
|
||||
ITEMS_IDENTIFIER_FIELD = "identifier"
|
||||
|
||||
SESSIONS_TOKEN_FIELD = "token"
|
||||
SESSIONS_CSRF_FIELD = "csrf"
|
||||
SESSIONS_USER_FIELD = "user"
|
||||
SESSIONS_IP_FIELD = "ip"
|
||||
SESSIONS_USER_AGENT_FIELD = "agent"
|
||||
@@ -512,8 +513,11 @@ const (
|
||||
SESSIONS_PERSIST_FIELD = "persist"
|
||||
|
||||
USERS_TABLE = "users"
|
||||
USERS_EMAIL_FIELD = "email"
|
||||
USERS_SETTINGS_FIELD = "settings"
|
||||
USERS_NAME_FIELD = "name"
|
||||
USERS_ROLE_FIELD = "role"
|
||||
USERS_AVATAR_FIELD = "avatar"
|
||||
|
||||
SESSION_COOKIE_NAME = "sid"
|
||||
)
|
||||
|
||||
78
dbmodels/session_generation.go
Normal file
78
dbmodels/session_generation.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package dbmodels
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types" // For types.NewDateTimeFromTime
|
||||
)
|
||||
|
||||
const (
|
||||
secureTokenByteLength = 64
|
||||
)
|
||||
|
||||
func generateSecureRandomToken(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = secureTokenByteLength
|
||||
}
|
||||
randomBytes := make([]byte, length)
|
||||
_, err := rand.Read(randomBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes for token: %w", err)
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(randomBytes), nil
|
||||
}
|
||||
|
||||
func CreateSessionToken(
|
||||
app core.App,
|
||||
userID string,
|
||||
ipAddress string,
|
||||
userAgent string,
|
||||
isPersistent bool,
|
||||
sessionDuration time.Duration,
|
||||
) (*Session, error) {
|
||||
|
||||
collection, err := app.FindCollectionByNameOrId(SESSIONS_TABLE)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find '%s' collection: %w", SESSIONS_TABLE, err)
|
||||
}
|
||||
|
||||
sessionTokenClear, err := generateSecureRandomToken(secureTokenByteLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
csrfTokenClear, err := generateSecureRandomToken(secureTokenByteLength)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
record := core.NewRecord(collection)
|
||||
session := NewSession(record)
|
||||
|
||||
// Set required fields with hashed tokens
|
||||
session.SetToken(sessionTokenClear)
|
||||
session.SetCSRF(csrfTokenClear)
|
||||
session.SetUser(userID)
|
||||
|
||||
date := types.NowDateTime()
|
||||
expires := date.Add(sessionDuration)
|
||||
session.SetExpires(expires)
|
||||
|
||||
session.SetPersist(isPersistent)
|
||||
session.SetLastAccess(types.NowDateTime())
|
||||
session.SetUserAgent(userAgent)
|
||||
session.SetIP(ipAddress)
|
||||
|
||||
if errSave := app.Save(session); errSave != nil {
|
||||
app.Logger().Error("Failed to save session token record", "error", errSave, "userID", userID)
|
||||
return nil, fmt.Errorf("failed to save session token record: %w", errSave)
|
||||
}
|
||||
|
||||
app.Logger().Info("Successfully created session token entry", "recordId", record.Id, "userID", userID)
|
||||
return session, nil
|
||||
}
|
||||
@@ -5,6 +5,24 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
type FixedSession struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
User string `json:"user"`
|
||||
Created string `json:"created"`
|
||||
Updated string `json:"updated"`
|
||||
Expires types.DateTime `json:"expires"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
LastAccess types.DateTime `json:"last_access"`
|
||||
Persist bool `json:"persist"`
|
||||
CSRF string `json:"csrf"`
|
||||
}
|
||||
|
||||
func (s *FixedSession) IsExpired() bool {
|
||||
return s.Expires.IsZero() || s.Expires.Before(types.NowDateTime())
|
||||
}
|
||||
|
||||
var _ core.RecordProxy = (*Place)(nil)
|
||||
|
||||
type Session struct {
|
||||
@@ -18,7 +36,7 @@ func NewSession(record *core.Record) *Session {
|
||||
}
|
||||
|
||||
func (u *Session) TableName() string {
|
||||
return USERS_TABLE
|
||||
return SESSIONS_TABLE
|
||||
}
|
||||
|
||||
func (u *Session) Token() string {
|
||||
@@ -84,3 +102,31 @@ func (u *Session) Persist() bool {
|
||||
func (u *Session) SetPersist(persist bool) {
|
||||
u.Set(SESSIONS_PERSIST_FIELD, persist)
|
||||
}
|
||||
|
||||
func (u *Session) CSRF() string {
|
||||
return u.GetString(SESSIONS_CSRF_FIELD)
|
||||
}
|
||||
|
||||
func (u *Session) SetCSRF(csrf string) {
|
||||
u.Set(SESSIONS_CSRF_FIELD, csrf)
|
||||
}
|
||||
|
||||
func (u *Session) IsExpired() bool {
|
||||
return u.Expires().IsZero() || u.Expires().Before(types.NowDateTime())
|
||||
}
|
||||
|
||||
func (u *Session) Fixed() FixedSession {
|
||||
return FixedSession{
|
||||
ID: u.Id,
|
||||
Token: u.Token(),
|
||||
User: u.User(),
|
||||
Created: u.Created(),
|
||||
Updated: u.Updated(),
|
||||
Expires: u.Expires(),
|
||||
IP: u.IP(),
|
||||
UserAgent: u.UserAgent(),
|
||||
LastAccess: u.LastAccess(),
|
||||
Persist: u.Persist(),
|
||||
CSRF: u.CSRF(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,18 @@ import (
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
)
|
||||
|
||||
type FixedUser struct {
|
||||
Id string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Created types.DateTime `json:"created"`
|
||||
Updated types.DateTime `json:"updated"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
Avatar string `json:"avatar"`
|
||||
Verified bool `json:"verified"`
|
||||
Settings string `json:"settings"`
|
||||
}
|
||||
|
||||
var _ core.RecordProxy = (*Place)(nil)
|
||||
|
||||
type User struct {
|
||||
@@ -22,7 +34,7 @@ func (u *User) TableName() string {
|
||||
return USERS_TABLE
|
||||
}
|
||||
|
||||
// INFO: Email is already set on the core.Record
|
||||
// INFO: Email, password functions are already set on the core.Record
|
||||
// TODO: We need to create a settings struct as soon as we have settings
|
||||
func (u *User) Name() string {
|
||||
return u.GetString(USERS_NAME_FIELD)
|
||||
@@ -59,3 +71,16 @@ func (u *User) Avatar() string {
|
||||
func (u *User) SetAvatar(avatar *filesystem.File) {
|
||||
u.Set(USERS_AVATAR_FIELD, avatar)
|
||||
}
|
||||
|
||||
func (u *User) Fixed() FixedUser {
|
||||
return FixedUser{
|
||||
Id: u.Id,
|
||||
Email: u.Email(),
|
||||
Created: u.Created(),
|
||||
Updated: u.Updated(),
|
||||
Name: u.Name(),
|
||||
Role: u.Role(),
|
||||
Avatar: u.Avatar(),
|
||||
Verified: u.Verified(),
|
||||
}
|
||||
}
|
||||
|
||||
145
helpers/collections/session_cache.go
Normal file
145
helpers/collections/session_cache.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||
)
|
||||
|
||||
type cacheEntry struct {
|
||||
user dbmodels.FixedUser
|
||||
session dbmodels.FixedSession
|
||||
}
|
||||
|
||||
type UserSessionCache struct {
|
||||
mu sync.RWMutex
|
||||
capacity int
|
||||
cache sync.Map
|
||||
approximateSize int
|
||||
cleanupInterval time.Duration
|
||||
stopCleanupSignal chan struct{}
|
||||
}
|
||||
|
||||
func NewUserSessionCache(capacity int, cleanupInterval time.Duration) *UserSessionCache {
|
||||
if capacity <= 0 {
|
||||
capacity = 1000
|
||||
}
|
||||
if cleanupInterval <= 0 {
|
||||
cleanupInterval = 5 * time.Minute
|
||||
}
|
||||
|
||||
cache := &UserSessionCache{
|
||||
capacity: capacity,
|
||||
cache: sync.Map{},
|
||||
cleanupInterval: cleanupInterval,
|
||||
stopCleanupSignal: make(chan struct{}),
|
||||
}
|
||||
go cache.startCleanupRoutine()
|
||||
return cache
|
||||
}
|
||||
|
||||
func (c *UserSessionCache) Set(user *dbmodels.User, session *dbmodels.Session) (*dbmodels.FixedUser, *dbmodels.FixedSession) {
|
||||
if user == nil || session == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
newEntry := &cacheEntry{
|
||||
user: user.Fixed(),
|
||||
session: session.Fixed(),
|
||||
}
|
||||
|
||||
_, loaded := c.cache.LoadOrStore(session.Token(), newEntry)
|
||||
if !loaded {
|
||||
c.cache.Store(session.Token(), newEntry)
|
||||
c.mu.Lock()
|
||||
c.approximateSize++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
return &newEntry.user, &newEntry.session
|
||||
}
|
||||
|
||||
func (c *UserSessionCache) Get(sessionTokenClear string) (*dbmodels.FixedUser, *dbmodels.FixedSession, bool) {
|
||||
if sessionTokenClear == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
value, ok := c.cache.Load(sessionTokenClear)
|
||||
if !ok {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
entry, ok := value.(*cacheEntry)
|
||||
if !ok {
|
||||
c.cache.Delete(sessionTokenClear)
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
if time.Now().After(entry.session.Expires.Time()) {
|
||||
c.cache.Delete(sessionTokenClear)
|
||||
c.mu.Lock()
|
||||
c.approximateSize--
|
||||
c.mu.Unlock()
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
return &entry.user, &entry.session, true
|
||||
}
|
||||
|
||||
func (c *UserSessionCache) Delete(sessionTokenClear string) {
|
||||
if sessionTokenClear == "" {
|
||||
return
|
||||
}
|
||||
_, loaded := c.cache.LoadAndDelete(sessionTokenClear)
|
||||
if loaded {
|
||||
c.mu.Lock()
|
||||
c.approximateSize--
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UserSessionCache) startCleanupRoutine() {
|
||||
ticker := time.NewTicker(c.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
c.cleanupExpiredItems()
|
||||
case <-c.stopCleanupSignal:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *UserSessionCache) cleanupExpiredItems() {
|
||||
now := time.Now()
|
||||
var newSize int
|
||||
c.cache.Range(func(key, value any) bool {
|
||||
entry, ok := value.(*cacheEntry)
|
||||
if !ok {
|
||||
c.cache.Delete(key)
|
||||
return true
|
||||
}
|
||||
|
||||
if now.After(entry.session.Expires.Time()) {
|
||||
c.cache.Delete(key)
|
||||
} else {
|
||||
newSize++
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
c.mu.Lock()
|
||||
c.approximateSize = newSize
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *UserSessionCache) StopCleanup() {
|
||||
select {
|
||||
case <-c.stopCleanupSignal:
|
||||
default:
|
||||
close(c.stopCleanupSignal)
|
||||
}
|
||||
}
|
||||
188
helpers/security/nonce_cache.go
Normal file
188
helpers/security/nonce_cache.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- NonceCache ---
|
||||
|
||||
type nonceEntry struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NonceCache stores nonces and their expiration times.
|
||||
// It is safe for concurrent use.
|
||||
type NonceCache struct {
|
||||
mu sync.RWMutex
|
||||
nonces map[string]nonceEntry
|
||||
defaultExpiration time.Duration
|
||||
cleanupInterval time.Duration
|
||||
stopCleanup chan struct{} // Channel to signal cleanup goroutine to stop
|
||||
}
|
||||
|
||||
// NewNonceCache creates a new in-memory nonce cache.
|
||||
// defaultExpiration: The default duration for which a nonce is valid.
|
||||
// cleanupInterval: How often to scan for and remove expired nonces.
|
||||
func NewNonceCache(defaultExpiration, cleanupInterval time.Duration) *NonceCache {
|
||||
if defaultExpiration <= 0 {
|
||||
defaultExpiration = 15 * time.Minute // Default to 15 minutes if invalid
|
||||
}
|
||||
if cleanupInterval <= 0 {
|
||||
cleanupInterval = 5 * time.Minute // Default to 5 minutes if invalid
|
||||
}
|
||||
nc := &NonceCache{
|
||||
nonces: make(map[string]nonceEntry),
|
||||
defaultExpiration: defaultExpiration,
|
||||
cleanupInterval: cleanupInterval,
|
||||
stopCleanup: make(chan struct{}),
|
||||
}
|
||||
go nc.startCleanupRoutine()
|
||||
return nc
|
||||
}
|
||||
|
||||
func (nc *NonceCache) Add(nonce string) {
|
||||
nc.addWithExpiration(nonce, nc.defaultExpiration)
|
||||
}
|
||||
|
||||
func (nc *NonceCache) addWithExpiration(nonce string, expiresIn time.Duration) {
|
||||
if nonce == "" {
|
||||
return
|
||||
}
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
nc.nonces[nonce] = nonceEntry{expiresAt: time.Now().Add(expiresIn)}
|
||||
}
|
||||
|
||||
func (nc *NonceCache) Use(nonce string) bool {
|
||||
if nonce == "" {
|
||||
return false
|
||||
}
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
|
||||
entry, exists := nc.nonces[nonce]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
delete(nc.nonces, nonce)
|
||||
return false
|
||||
}
|
||||
|
||||
delete(nc.nonces, nonce)
|
||||
return true
|
||||
}
|
||||
|
||||
func (nc *NonceCache) startCleanupRoutine() {
|
||||
ticker := time.NewTicker(nc.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
nc.cleanupExpiredNonces()
|
||||
case <-nc.stopCleanup:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (nc *NonceCache) cleanupExpiredNonces() {
|
||||
nc.mu.Lock()
|
||||
defer nc.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
slog.Debug("Cleaning up expired nonces", "current_time", now, "nonces_count", len(nc.nonces))
|
||||
for nonce, entry := range nc.nonces {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(nc.nonces, nonce)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (nc *NonceCache) StopCleanup() {
|
||||
select {
|
||||
case <-nc.stopCleanup:
|
||||
default:
|
||||
close(nc.stopCleanup)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultServerSecretSize = 32 // bytes
|
||||
defaultNonceSize = 16 // bytes for raw nonce before encoding
|
||||
)
|
||||
|
||||
type CSRFProtector struct {
|
||||
serverSecret []byte
|
||||
nonceCache *NonceCache
|
||||
}
|
||||
|
||||
func NewCSRFProtector(nonceExpiration, nonceCleanupInterval time.Duration) (*CSRFProtector, error) {
|
||||
secretToUse := make([]byte, defaultServerSecretSize)
|
||||
if _, err := rand.Read(secretToUse); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate server secret: %w", err)
|
||||
}
|
||||
|
||||
return &CSRFProtector{
|
||||
serverSecret: secretToUse,
|
||||
nonceCache: NewNonceCache(nonceExpiration, nonceCleanupInterval),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CSRFProtector) GenerateTokenBundle() (nonceB64 string, validationTokenB64 string, err error) {
|
||||
nonceBytes := make([]byte, defaultNonceSize)
|
||||
if _, errRand := rand.Read(nonceBytes); errRand != nil {
|
||||
return "", "", fmt.Errorf("failed to generate nonce bytes: %w", errRand)
|
||||
}
|
||||
nonceB64 = base64.URLEncoding.EncodeToString(nonceBytes)
|
||||
|
||||
p.nonceCache.Add(nonceB64)
|
||||
|
||||
mac := hmac.New(sha256.New, p.serverSecret)
|
||||
mac.Write([]byte(nonceB64)) // Sign the base64 encoded nonce string
|
||||
validationTokenBytes := mac.Sum(nil)
|
||||
validationTokenB64 = base64.URLEncoding.EncodeToString(validationTokenBytes)
|
||||
|
||||
return nonceB64, validationTokenB64, nil
|
||||
}
|
||||
|
||||
func (p *CSRFProtector) ValidateTokenBundle(nonceSubmittedB64 string, validationTokenSubmittedB64 string) (bool, error) {
|
||||
if nonceSubmittedB64 == "" || validationTokenSubmittedB64 == "" {
|
||||
return false, errors.New("submitted nonce or validation token is empty")
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, p.serverSecret)
|
||||
mac.Write([]byte(nonceSubmittedB64))
|
||||
expectedMACTokenBytes := mac.Sum(nil)
|
||||
|
||||
validationTokenSubmittedBytes, err := base64.URLEncoding.DecodeString(validationTokenSubmittedB64)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to decode submitted validation token: %w", err)
|
||||
}
|
||||
|
||||
if !hmac.Equal(validationTokenSubmittedBytes, expectedMACTokenBytes) {
|
||||
return false, errors.New("validation token (HMAC) mismatch")
|
||||
}
|
||||
|
||||
if !p.nonceCache.Use(nonceSubmittedB64) {
|
||||
return false, errors.New("nonce not found in cache, expired, or already used")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (p *CSRFProtector) StopNonceCacheCleanup() {
|
||||
if p.nonceCache != nil {
|
||||
p.nonceCache.StopCleanup()
|
||||
}
|
||||
}
|
||||
72
middleware/authenticated.go
Normal file
72
middleware/authenticated.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/collections"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
var cache = collections.NewUserSessionCache(1000, 5*time.Minute)
|
||||
var deact_cookie = &http.Cookie{
|
||||
Name: dbmodels.SESSION_COOKIE_NAME,
|
||||
MaxAge: -1,
|
||||
Path: "/",
|
||||
}
|
||||
|
||||
func Authenticated(app core.App) func(*core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
if strings.HasPrefix(e.Request.URL.Path, "/assets") ||
|
||||
strings.HasPrefix(e.Request.URL.Path, "/api") {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
cookie, err := e.Request.Cookie(dbmodels.SESSION_COOKIE_NAME)
|
||||
if err != nil {
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
user, session, loaded := cache.Get(cookie.Value)
|
||||
if !loaded {
|
||||
record, err := app.FindFirstRecordByData(dbmodels.SESSIONS_TABLE, dbmodels.SESSIONS_TOKEN_FIELD, cookie.Value)
|
||||
if err != nil {
|
||||
e.SetCookie(deact_cookie)
|
||||
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
s := dbmodels.NewSession(record)
|
||||
r, err := app.FindRecordById(dbmodels.USERS_TABLE, s.User())
|
||||
if err != nil {
|
||||
e.SetCookie(deact_cookie)
|
||||
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
|
||||
return e.Next()
|
||||
}
|
||||
u := dbmodels.NewUser(r)
|
||||
user, session = cache.Set(u, s)
|
||||
}
|
||||
|
||||
slog.Debug("User session detected", "user", user.Id, "name", user.Name, "session", session.ID)
|
||||
|
||||
if session.IsExpired() {
|
||||
slog.Warn("Session expired", "user", user.Id, "name", user.Name, "session", session.ID)
|
||||
cache.Delete(cookie.Value)
|
||||
r, err := app.FindRecordById(dbmodels.SESSIONS_TABLE, session.ID)
|
||||
e.SetCookie(deact_cookie)
|
||||
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
|
||||
if err == nil {
|
||||
app.Delete(r)
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
|
||||
e.Set("user", user)
|
||||
e.Set("session", session)
|
||||
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,11 @@ func sessionTokensFields(usersCollectionId string) core.FieldsList {
|
||||
Required: true,
|
||||
Presentable: false,
|
||||
},
|
||||
&core.TextField{
|
||||
Name: dbmodels.SESSIONS_CSRF_FIELD,
|
||||
Required: true,
|
||||
Presentable: false,
|
||||
},
|
||||
&core.RelationField{
|
||||
Name: dbmodels.SESSIONS_USER_FIELD,
|
||||
Required: true,
|
||||
@@ -80,7 +85,6 @@ func sessionTokensFields(usersCollectionId string) core.FieldsList {
|
||||
},
|
||||
&core.BoolField{
|
||||
Name: dbmodels.SESSIONS_PERSIST_FIELD,
|
||||
Required: true,
|
||||
Presentable: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ func init() {
|
||||
Required: true,
|
||||
Presentable: true,
|
||||
MaxSelect: 1,
|
||||
Values: []string{"admin", "editor", "viewer"},
|
||||
Values: dbmodels.USER_ROLES,
|
||||
}
|
||||
|
||||
collection.Fields.Add(settingsField)
|
||||
|
||||
143
pages/login.go
Normal file
143
pages/login.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/security"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/router"
|
||||
)
|
||||
|
||||
const (
|
||||
URL_LOGIN = "/login/"
|
||||
TEMPLATE_LOGIN = "/login/"
|
||||
)
|
||||
|
||||
// TODO:
|
||||
// - rate limiting
|
||||
// - maybe csrf
|
||||
|
||||
func init() {
|
||||
csrf_cache, err := security.NewCSRFProtector(time.Minute*5, time.Minute)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
lp := &LoginPage{
|
||||
StaticPage: pagemodels.StaticPage{
|
||||
Name: pagemodels.P_LOGIN_NAME,
|
||||
Layout: "blank",
|
||||
Template: TEMPLATE_LOGIN,
|
||||
URL: URL_LOGIN,
|
||||
},
|
||||
csrf_cache: csrf_cache,
|
||||
}
|
||||
app.Register(lp)
|
||||
}
|
||||
|
||||
type LoginPage struct {
|
||||
pagemodels.StaticPage
|
||||
csrf_cache *security.CSRFProtector
|
||||
}
|
||||
|
||||
func (p *LoginPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
|
||||
router.GET(URL_LOGIN, p.GET(engine))
|
||||
router.POST(URL_LOGIN, p.POST(engine, app))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LoginPage) GET(engine *templating.Engine) HandleFunc {
|
||||
return func(e *core.RequestEvent) error {
|
||||
data := make(map[string]any)
|
||||
data["record"] = p
|
||||
nonce, token, err := p.csrf_cache.GenerateTokenBundle()
|
||||
if err != nil {
|
||||
return engine.Response500(e, err, data)
|
||||
}
|
||||
data["csrf_nonce"] = nonce
|
||||
data["csrf_token"] = token
|
||||
|
||||
// TODO: the function to delete tokens is not yet there
|
||||
// as of right now, the tokens get only deleted from the clients
|
||||
// We need to delete the tokens from the cache + table.
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: dbmodels.SESSION_COOKIE_NAME,
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
})
|
||||
e.Response.Header().Set("Clear-Site-Data", "\"cookies\"")
|
||||
|
||||
return engine.Response200(e, p.Template, data, p.Layout)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *LoginPage) POST(engine *templating.Engine, app core.App) HandleFunc {
|
||||
return func(e *core.RequestEvent) error {
|
||||
data := make(map[string]any)
|
||||
data["record"] = p
|
||||
|
||||
formdata := struct {
|
||||
Username string `json:"username" form:"username"`
|
||||
Password string `json:"password" form:"password"`
|
||||
CsrfNonce string `json:"csrf_nonce" form:"csrf_nonce"`
|
||||
CsrfToken string `json:"csrf_token" form:"csrf_token"`
|
||||
Persistent string `json:"persist" form:"persist"`
|
||||
}{}
|
||||
|
||||
if err := e.BindBody(&formdata); err != nil {
|
||||
return engine.Response500(e, err, data)
|
||||
}
|
||||
|
||||
if _, err := p.csrf_cache.ValidateTokenBundle(formdata.CsrfNonce, formdata.CsrfToken); err != nil {
|
||||
return engine.Response403(e, err, data)
|
||||
}
|
||||
|
||||
if formdata.Username == "" || formdata.Password == "" {
|
||||
return engine.Response403(e, fmt.Errorf("Username and password are required"), data)
|
||||
}
|
||||
|
||||
record, err := app.FindFirstRecordByData(dbmodels.USERS_TABLE, dbmodels.USERS_EMAIL_FIELD, formdata.Username)
|
||||
if err != nil || !record.ValidatePassword(formdata.Password) {
|
||||
return engine.Response403(e, err, data)
|
||||
}
|
||||
|
||||
duration := time.Minute * 60
|
||||
if formdata.Persistent == "on" {
|
||||
duration = time.Hour * 24 * 90
|
||||
}
|
||||
|
||||
token, err := dbmodels.CreateSessionToken(app, record.Id, e.RealIP(), e.Request.UserAgent(), formdata.Persistent == "on", duration)
|
||||
if err != nil {
|
||||
return engine.Response500(e, err, data)
|
||||
}
|
||||
|
||||
if formdata.Persistent == "on" {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: dbmodels.SESSION_COOKIE_NAME,
|
||||
Path: "/",
|
||||
MaxAge: int(duration.Seconds()),
|
||||
Value: token.Token(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
})
|
||||
} else {
|
||||
e.SetCookie(&http.Cookie{
|
||||
Name: dbmodels.SESSION_COOKIE_NAME,
|
||||
Path: "/",
|
||||
Value: token.Token(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
})
|
||||
}
|
||||
|
||||
return e.Redirect(303, "/reihen")
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func (p *ReihenPage) Setup(router *router.Router[*core.RequestEvent], app core.A
|
||||
|
||||
func (p *ReihenPage) YearRequest(app core.App, engine *templating.Engine, e *core.RequestEvent) error {
|
||||
year := e.Request.URL.Query().Get(PARAM_YEAR)
|
||||
data := map[string]interface{}{}
|
||||
data := make(map[string]any)
|
||||
data[PARAM_HIDDEN] = e.Request.URL.Query().Get(PARAM_HIDDEN)
|
||||
data[PARAM_YEAR] = year
|
||||
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
type HandleFunc func(e *core.RequestEvent) error
|
||||
|
||||
func init() {
|
||||
RegisterStaticPage("/datenschutz/", pagemodels.P_DATENSCHUTZ_NAME)
|
||||
RegisterStaticPage("/login/", pagemodels.P_LOGIN_NAME, "blank")
|
||||
RegisterTextPage("/redaktion/kontakt/", pagemodels.P_KONTAKT_NAME)
|
||||
RegisterTextPage("/redaktion/danksagungen/", pagemodels.P_DANK_NAME)
|
||||
RegisterTextPage("/redaktion/literatur/", pagemodels.P_LIT_NAME)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
|
||||
"github.com/Theodor-Springmann-Stiftung/musenalm/helpers/functions"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"golang.org/x/net/websocket"
|
||||
@@ -70,7 +71,7 @@ type Engine struct {
|
||||
|
||||
mu *sync.Mutex
|
||||
FuncMap template.FuncMap
|
||||
GlobalData map[string]interface{}
|
||||
GlobalData map[string]any
|
||||
}
|
||||
|
||||
// INFO: We pass the app here to be able to access the config and other data for functions
|
||||
@@ -82,7 +83,7 @@ func NewEngine(layouts, templates *fs.FS) *Engine {
|
||||
LayoutRegistry: NewLayoutRegistry(*layouts),
|
||||
TemplateRegistry: NewTemplateRegistry(*templates),
|
||||
FuncMap: make(template.FuncMap),
|
||||
GlobalData: make(map[string]interface{}),
|
||||
GlobalData: make(map[string]any),
|
||||
}
|
||||
e.funcs()
|
||||
return &e
|
||||
@@ -142,7 +143,7 @@ func (e *Engine) funcs() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) Globals(data map[string]interface{}) {
|
||||
func (e *Engine) Globals(data map[string]any) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.GlobalData == nil {
|
||||
@@ -186,13 +187,13 @@ func (e *Engine) Refresh() {
|
||||
}
|
||||
|
||||
// INFO: fn is a function that returns either one value or two values, the second one being an error
|
||||
func (e *Engine) AddFunc(name string, fn interface{}) {
|
||||
func (e *Engine) AddFunc(name string, fn any) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.FuncMap[name] = fn
|
||||
}
|
||||
|
||||
func (e *Engine) AddFuncs(funcs map[string]interface{}) {
|
||||
func (e *Engine) AddFuncs(funcs map[string]any) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
for k, v := range funcs {
|
||||
@@ -200,10 +201,10 @@ func (e *Engine) AddFuncs(funcs map[string]interface{}) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) Render(out io.Writer, path string, ld map[string]interface{}, layout ...string) error {
|
||||
func (e *Engine) Render(out io.Writer, path string, ld map[string]any, layout ...string) error {
|
||||
gd := e.GlobalData
|
||||
if ld == nil {
|
||||
ld = make(map[string]interface{})
|
||||
ld = make(map[string]any)
|
||||
}
|
||||
|
||||
// INFO: don't pollute the global data space
|
||||
@@ -251,9 +252,30 @@ func (e *Engine) Render(out io.Writer, path string, ld map[string]interface{}, l
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) Response404(request *core.RequestEvent, err error, data map[string]interface{}) error {
|
||||
func (e *Engine) Response403(request *core.RequestEvent, err error, data map[string]any) error {
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
data = make(map[string]any)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
if err != nil {
|
||||
request.App.Logger().Error("Unauthorized 403 error fetching URL!", "error", err, "request", request.Request.URL)
|
||||
data["Error"] = err.Error()
|
||||
}
|
||||
|
||||
data["page"] = requestData(request)
|
||||
|
||||
err2 := e.Render(&sb, "/errors/403/", data)
|
||||
if err2 != nil {
|
||||
return e.Response500(request, errors.Join(err, err2), data)
|
||||
}
|
||||
|
||||
return request.HTML(http.StatusNotFound, sb.String())
|
||||
}
|
||||
|
||||
func (e *Engine) Response404(request *core.RequestEvent, err error, data map[string]any) error {
|
||||
if data == nil {
|
||||
data = make(map[string]any)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
@@ -272,9 +294,9 @@ func (e *Engine) Response404(request *core.RequestEvent, err error, data map[str
|
||||
return request.HTML(http.StatusNotFound, sb.String())
|
||||
}
|
||||
|
||||
func (e *Engine) Response500(request *core.RequestEvent, err error, data map[string]interface{}) error {
|
||||
func (e *Engine) Response500(request *core.RequestEvent, err error, data map[string]any) error {
|
||||
if data == nil {
|
||||
data = make(map[string]interface{})
|
||||
data = make(map[string]any)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
@@ -293,9 +315,9 @@ func (e *Engine) Response500(request *core.RequestEvent, err error, data map[str
|
||||
return request.HTML(http.StatusInternalServerError, sb.String())
|
||||
}
|
||||
|
||||
func (e *Engine) Response200(request *core.RequestEvent, path string, ld map[string]interface{}, layout ...string) error {
|
||||
func (e *Engine) Response200(request *core.RequestEvent, path string, ld map[string]any, layout ...string) error {
|
||||
if ld == nil {
|
||||
ld = make(map[string]interface{})
|
||||
ld = make(map[string]any)
|
||||
}
|
||||
|
||||
ld["page"] = requestData(request)
|
||||
@@ -317,11 +339,26 @@ func (e *Engine) Response200(request *core.RequestEvent, path string, ld map[str
|
||||
return request.HTML(http.StatusOK, tstring)
|
||||
}
|
||||
|
||||
func requestData(request *core.RequestEvent) map[string]interface{} {
|
||||
data := make(map[string]interface{})
|
||||
func requestData(request *core.RequestEvent) map[string]any {
|
||||
data := make(map[string]any)
|
||||
data["Path"] = request.Request.URL.Path
|
||||
data["Query"] = request.Request.URL.Query()
|
||||
data["Method"] = request.Request.Method
|
||||
data["Host"] = request.Request.Host
|
||||
|
||||
if user := request.Get("user"); user != nil {
|
||||
u, ok := user.(*dbmodels.FixedUser)
|
||||
if ok {
|
||||
data["User"] = u
|
||||
}
|
||||
}
|
||||
|
||||
if session := request.Get("session"); session != nil {
|
||||
u, ok := session.(*dbmodels.FixedSession)
|
||||
if ok {
|
||||
data["Session"] = u
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,8 +18,17 @@
|
||||
<i class="ri-code-line"></i>
|
||||
<a href="https://github.com/Theodor-Springmann-Stiftung/musenalm">Code</a>
|
||||
<span>·</span>
|
||||
<i class="ri-login-box-line"></i>
|
||||
<a href="/login">Login</a>
|
||||
{{ if .page.User }}
|
||||
<i class="ri-user-3-line"></i>
|
||||
Eingeloggt als
|
||||
{{ .page.User.Email }}
|
||||
|
|
||||
<i class="ri-logout-box-line"></i>
|
||||
<a href="/login">Logout</a>
|
||||
{{ else }}
|
||||
<i class="ri-login-box-line"></i>
|
||||
<a href="/login">Login</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{{ $model := . }}
|
||||
|
||||
|
||||
<div class="flex max-w-md mx-auto !pt-44">
|
||||
<div class="flex-col w-full">
|
||||
<a href="/" class="text-gray-700 hover:text-slate-950">
|
||||
@@ -9,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="text-4xl self-baseline text-center w-full mt-6">Musenalm | Login</h1>
|
||||
<form class="mt-9 w-full grid grid-cols-3 gap-4" method="POST" action="/login">
|
||||
<form class="mt-9 w-full grid grid-cols-3 gap-4" method="POST" action="/login/">
|
||||
<div
|
||||
class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5
|
||||
bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100">
|
||||
@@ -17,7 +20,7 @@
|
||||
E-Mail <i class="ri-at-line"></i>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
type="email"
|
||||
name="username"
|
||||
id="username"
|
||||
class="mt-1 block w-full rounded-md focus:border-none focus:outline-none"
|
||||
@@ -42,7 +45,29 @@
|
||||
required
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="col-start-2 col-span-2">
|
||||
<div class="flex justify-end items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="persist"
|
||||
id="persist"
|
||||
class="h-4 w-4 text-slate-600 focus:ring-slate-500 border-gray-300 rounded" />
|
||||
<label for="persist" class="ml-2 block text-sm text-gray-900"> Angemeldet bleiben </label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_nonce"
|
||||
id="csrf_nonce"
|
||||
required
|
||||
value="{{ $model.csrf_nonce }}" />
|
||||
<input
|
||||
type="hidden"
|
||||
name="csrf_token"
|
||||
id="csrf_token"
|
||||
required
|
||||
value="{{ $model.csrf_token }}" />
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full inline-flex justify-center py-2 px-4 border border-transparent rounded-md
|
||||
|
||||
Reference in New Issue
Block a user