user authentication
This commit is contained in:
107
internal/server/middleware.go
Normal file
107
internal/server/middleware.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const csrfSessionKey = "csrf_token"
|
||||
|
||||
func (s *Server) randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// SessionUser loads gorilla session and exposes user_id and csrf token to context
|
||||
func (s *Server) SessionUser() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
sess, _ := s.store.Get(c.Request, "gobsidian_session")
|
||||
if v, ok := sess.Values["user_id"].(int64); ok {
|
||||
c.Set("user_id", v)
|
||||
// derive admin flag
|
||||
if ok, err := s.auth.IsUserInGroup(v, "admin"); err == nil && ok {
|
||||
c.Set("is_admin", true)
|
||||
}
|
||||
}
|
||||
// ensure CSRF token exists in session
|
||||
tok, _ := sess.Values[csrfSessionKey].(string)
|
||||
if tok == "" {
|
||||
if t, err := s.randomToken(32); err == nil {
|
||||
sess.Values[csrfSessionKey] = t
|
||||
_ = sess.Save(c.Request, c.Writer)
|
||||
tok = t
|
||||
}
|
||||
}
|
||||
c.Set("csrf_token", tok)
|
||||
// expose CSRF token to client: header + non-HttpOnly cookie
|
||||
if tok != "" {
|
||||
c.Writer.Header().Set("X-CSRF-Token", tok)
|
||||
// cookie accessible to JS (HttpOnly=false). Secure/ SameSite Lax for CSRF
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: "csrf_token",
|
||||
Value: tok,
|
||||
Path: "/",
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// CSRFRequire validates the CSRF token for state-changing requests
|
||||
func (s *Server) CSRFRequire() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
sess, _ := s.store.Get(c.Request, "gobsidian_session")
|
||||
expected, _ := sess.Values[csrfSessionKey].(string)
|
||||
var token string
|
||||
if h := c.GetHeader("X-CSRF-Token"); h != "" {
|
||||
token = h
|
||||
} else {
|
||||
token = c.PostForm("csrf_token")
|
||||
}
|
||||
if expected == "" || token == "" || expected != token {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "invalid CSRF token"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAuth enforces authenticated access
|
||||
func (s *Server) RequireAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAdmin enforces admin-only access
|
||||
func (s *Server) RequireAdmin() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if _, exists := c.Get("user_id"); !exists {
|
||||
c.Redirect(http.StatusFound, "/editor/login")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if _, ok := c.Get("is_admin"); !ok {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin required"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/sessions"
|
||||
|
||||
"gobsidian/internal/auth"
|
||||
"gobsidian/internal/config"
|
||||
"gobsidian/internal/handlers"
|
||||
"gobsidian/internal/models"
|
||||
@@ -18,6 +19,7 @@ type Server struct {
|
||||
config *config.Config
|
||||
router *gin.Engine
|
||||
store *sessions.CookieStore
|
||||
auth *auth.Service
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Server {
|
||||
@@ -28,12 +30,22 @@ func New(cfg *config.Config) *Server {
|
||||
router := gin.Default()
|
||||
store := sessions.NewCookieStore([]byte(cfg.SecretKey))
|
||||
|
||||
// Initialize auth service (panic on error during startup)
|
||||
authSvc, err := auth.Open(cfg)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to initialize auth: %w", err))
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
config: cfg,
|
||||
router: router,
|
||||
store: store,
|
||||
auth: authSvc,
|
||||
}
|
||||
|
||||
// Global middlewares: session user + template setup
|
||||
s.router.Use(s.SessionUser())
|
||||
|
||||
s.setupRoutes()
|
||||
s.setupStaticFiles()
|
||||
s.setupTemplates()
|
||||
@@ -62,7 +74,7 @@ func (s *Server) Start() error {
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() {
|
||||
h := handlers.New(s.config, s.store)
|
||||
h := handlers.New(s.config, s.store, s.auth)
|
||||
|
||||
// Main routes
|
||||
s.router.GET("/", h.IndexHandler)
|
||||
@@ -74,27 +86,68 @@ func (s *Server) setupRoutes() {
|
||||
s.router.GET("/serve_stored_image/:filename", h.ServeStoredImageHandler)
|
||||
s.router.GET("/download/*path", h.DownloadHandler)
|
||||
s.router.GET("/view_text/*path", h.ViewTextHandler)
|
||||
s.router.GET("/edit_text/*path", h.EditTextPageHandler)
|
||||
s.router.POST("/edit_text/*path", h.PostEditTextHandler)
|
||||
|
||||
// Upload routes
|
||||
s.router.POST("/upload", h.UploadHandler)
|
||||
// Auth routes
|
||||
s.router.GET("/editor/login", h.LoginPage)
|
||||
s.router.POST("/editor/login", s.CSRFRequire(), h.LoginPost)
|
||||
s.router.POST("/editor/logout", s.RequireAuth(), s.CSRFRequire(), h.LogoutPost)
|
||||
// MFA challenge routes (no auth yet, but CSRF)
|
||||
s.router.GET("/editor/mfa", s.CSRFRequire(), h.MFALoginPage)
|
||||
s.router.POST("/editor/mfa", s.CSRFRequire(), h.MFALoginVerify)
|
||||
|
||||
// Settings routes
|
||||
s.router.GET("/settings", h.SettingsPageHandler)
|
||||
s.router.GET("/settings/image_storage", h.GetImageStorageSettingsHandler)
|
||||
s.router.POST("/settings/image_storage", h.PostImageStorageSettingsHandler)
|
||||
s.router.GET("/settings/notes_dir", h.GetNotesDirSettingsHandler)
|
||||
s.router.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler)
|
||||
s.router.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler)
|
||||
s.router.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler)
|
||||
// New /editor group protected by auth + CSRF
|
||||
editor := s.router.Group("/editor", s.RequireAuth(), s.CSRFRequire())
|
||||
{
|
||||
editor.GET("/create", h.CreateNotePageHandler)
|
||||
editor.POST("/create", h.CreateNoteHandler)
|
||||
editor.GET("/edit/*path", h.EditNotePageHandler)
|
||||
editor.POST("/edit/*path", h.EditNoteHandler)
|
||||
editor.DELETE("/delete/*path", h.DeleteHandler)
|
||||
|
||||
// Editor routes
|
||||
s.router.GET("/create", h.CreateNotePageHandler)
|
||||
s.router.POST("/create", h.CreateNoteHandler)
|
||||
s.router.GET("/edit/*path", h.EditNotePageHandler)
|
||||
s.router.POST("/edit/*path", h.EditNoteHandler)
|
||||
s.router.DELETE("/delete/*path", h.DeleteHandler)
|
||||
// Text editor routes under /editor
|
||||
editor.GET("/edit_text/*path", h.EditTextPageHandler)
|
||||
editor.POST("/edit_text/*path", h.PostEditTextHandler)
|
||||
|
||||
// Upload under /editor (secured)
|
||||
editor.POST("/upload", h.UploadHandler)
|
||||
|
||||
// Settings under /editor
|
||||
editor.GET("/settings", h.SettingsPageHandler)
|
||||
editor.GET("/settings/image_storage", h.GetImageStorageSettingsHandler)
|
||||
editor.POST("/settings/image_storage", h.PostImageStorageSettingsHandler)
|
||||
editor.GET("/settings/notes_dir", h.GetNotesDirSettingsHandler)
|
||||
editor.POST("/settings/notes_dir", h.PostNotesDirSettingsHandler)
|
||||
editor.GET("/settings/file_extensions", h.GetFileExtensionsSettingsHandler)
|
||||
editor.POST("/settings/file_extensions", h.PostFileExtensionsSettingsHandler)
|
||||
|
||||
// Profile
|
||||
editor.GET("/profile", h.ProfilePage)
|
||||
editor.POST("/profile/password", h.PostProfileChangePassword)
|
||||
editor.POST("/profile/email", h.PostProfileChangeEmail)
|
||||
editor.POST("/profile/mfa/enable", h.PostProfileEnableMFA)
|
||||
editor.POST("/profile/mfa/disable", h.PostProfileDisableMFA)
|
||||
// MFA setup during enrollment
|
||||
editor.GET("/profile/mfa/setup", h.ProfileMFASetupPage)
|
||||
editor.POST("/profile/mfa/verify", h.ProfileMFASetupVerify)
|
||||
|
||||
// Admin dashboard
|
||||
editor.GET("/admin", s.RequireAdmin(), h.AdminPage)
|
||||
|
||||
// Admin CRUD API under /editor/admin
|
||||
admin := editor.Group("/admin", s.RequireAdmin())
|
||||
{
|
||||
admin.POST("/users", h.AdminCreateUser)
|
||||
admin.DELETE("/users/:id", h.AdminDeleteUser)
|
||||
admin.POST("/users/:id/active", h.AdminSetUserActive)
|
||||
admin.POST("/users/:id/mfa/enable", h.AdminEnableUserMFA)
|
||||
admin.POST("/users/:id/mfa/disable", h.AdminDisableUserMFA)
|
||||
admin.POST("/users/:id/mfa/reset", h.AdminResetUserMFA)
|
||||
admin.POST("/groups", h.AdminCreateGroup)
|
||||
admin.DELETE("/groups/:id", h.AdminDeleteGroup)
|
||||
admin.POST("/memberships/add", h.AdminAddUserToGroup)
|
||||
admin.POST("/memberships/remove", h.AdminRemoveUserFromGroup)
|
||||
}
|
||||
}
|
||||
|
||||
// API routes
|
||||
s.router.GET("/api/tree", h.TreeAPIHandler)
|
||||
|
||||
Reference in New Issue
Block a user