All: Enhance session management and security features

Using proper auth + security on login.
jocadbz
Joca 2025-12-21 21:26:02 -03:00
parent 876ac33d1b
commit ef06bf160a
Signed by: jocadbz
GPG Key ID: B1836DCE2F50BDF7
7 changed files with 331 additions and 294 deletions

View File

@ -12,7 +12,7 @@ This, of course, assumes you have a decent understanding of Go.
### Configuration Files
* **config/config.json.sample**:
This file provides a template for the main application configuration. It defines critical parameters for the application to run, such as database credentials, domain, and file storage locations.
This file provides a template for the main application configuration. It defines critical parameters for the application to run, such as database credentials, domain, file storage locations, and session security.
Example content:
{
"domain_name": "localhost",
@ -21,8 +21,13 @@ This, of course, assumes you have a decent understanding of Go.
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
"file_storage_dir": "files",
"session_secret": "change-me-to-32-byte-random",
"session_secure": false
}
Notes:
- `session_secret` should be a 32+ byte random value. At runtime, it is overridden by the `THREADR_SESSION_SECRET` environment variable if present (recommended for production).
- `session_secure` controls whether cookies are marked `Secure`; set to `true` in HTTPS environments.
* **config/config.json**:
The active configuration file, copied from `config.json.sample` and modified for the specific deployment. Contains sensitive information like database passwords.
@ -55,19 +60,9 @@ This directory contains the HTTP handler functions that process incoming request
* **handlers/app.go**:
Defines common application-wide structures and middleware:
- `PageData`: A struct holding data passed to HTML templates for rendering common elements (title, navbar state, login status, cookie banner, base paths, current URL).
- `Config`: A struct to unmarshal application configuration from `config.json`.
Example JSON for `Config`:
{
"domain_name": "localhost",
"threadr_dir": "/threadr",
"db_username": "threadr_user",
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
}
- `Config`: A struct to unmarshal application configuration from `config.json` (and env overrides). Fields include DB settings, domain, file storage dir, `session_secret`, and `session_secure`.
- `App`: The main application context struct, holding pointers to the database connection, session store, configuration, and templates.
- `SessionMW`: Middleware to retrieve or create a new Gorilla session for each request, making the session available in the request context.
- `SessionMW`: Middleware to retrieve or create a new Gorilla session for each request, applying secure cookie options (HttpOnly, SameSite=Lax, Secure configurable) and attaching the session to the request context.
- `RequireLoginMW`: Middleware to enforce user authentication for specific routes, redirecting unauthenticated users to the login page.
* **handlers/about.go**:

View File

@ -5,5 +5,7 @@
"db_password": "threadr_password",
"db_database": "threadr_db",
"db_svr_host": "localhost:3306",
"file_storage_dir": "files"
"file_storage_dir": "files",
"session_secret": "change-me-to-32-byte-random",
"session_secure": false
}

5
go.mod
View File

@ -6,11 +6,12 @@ require (
github.com/go-sql-driver/mysql v1.9.0
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.0
golang.org/x/crypto v0.45.0
golang.org/x/term v0.37.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/sys v0.38.0 // indirect
)

10
go.sum
View File

@ -10,7 +10,9 @@ github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzq
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=

View File

@ -28,6 +28,8 @@ type Config struct {
DBDatabase string `json:"db_database"`
DBServerHost string `json:"db_svr_host"`
FileStorageDir string `json:"file_storage_dir"`
SessionSecret string `json:"session_secret"`
SessionSecure bool `json:"session_secure"`
}
type App struct {
@ -42,13 +44,11 @@ func (app *App) SessionMW(next http.HandlerFunc) http.HandlerFunc {
session, err := app.Store.Get(r, "session-name")
if err != nil {
session = sessions.NewSession(app.Store, "session-name")
session.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days
HttpOnly: true,
}
}
// Enforce secure cookie options on every request.
session.Options = app.cookieOptions(r)
ctx := context.WithValue(r.Context(), "session", session)
r = r.WithContext(ctx)
@ -76,3 +76,21 @@ func (app *App) RequireLoginMW(next http.HandlerFunc) http.HandlerFunc {
next(w, r)
}
}
func (app *App) cookieOptions(r *http.Request) *sessions.Options {
secure := app.Config.SessionSecure
if r.TLS != nil {
secure = true
} // I dunno what I am doing honestly
options := &sessions.Options{
Path: app.Config.ThreadrDir + "/",
MaxAge: 86400 * 30,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
}
if app.Config.DomainName != "" {
options.Domain = app.Config.DomainName
}
return options
}

View File

@ -5,6 +5,7 @@ import (
"log"
"net/http"
"threadr/models"
"github.com/gorilla/sessions"
)
@ -24,14 +25,14 @@ func LoginHandler(app *App) http.HandlerFunc {
http.Redirect(w, r, app.Config.ThreadrDir+"/login/?error=invalid", http.StatusFound)
return
}
// Regenerate session to avoid fixation
session.Options.MaxAge = -1
_ = session.Save(r, w)
session = sessions.NewSession(app.Store, "session-name")
session.Options = app.cookieOptions(r)
session.Values["user_id"] = user.ID
session.Values["user_ip"] = r.RemoteAddr
session.Values["user_agent"] = r.UserAgent()
session.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 30, // 30 days
HttpOnly: true,
}
if err := session.Save(r, w); err != nil {
log.Printf("Error saving session: %v", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)

20
main.go
View File

@ -294,6 +294,14 @@ func main() {
log.Fatal("Error loading config:", err)
}
// Allow environment variable override for the session secret to avoid hardcoding secrets in files.
if envSecret := os.Getenv("THREADR_SESSION_SECRET"); envSecret != "" {
config.SessionSecret = envSecret
}
if len(config.SessionSecret) < 32 {
log.Fatal("Session secret must be at least 32 bytes; set THREADR_SESSION_SECRET or session_secret in config")
}
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s", config.DBUsername, config.DBPassword, config.DBServerHost, config.DBDatabase)
db, err := sql.Open("mysql", dsn)
if err != nil {
@ -359,7 +367,17 @@ func main() {
log.Fatal("Error parsing page templates:", err)
}
store := sessions.NewCookieStore([]byte("secret-key")) // Replace with secure key in production
store := sessions.NewCookieStore([]byte(config.SessionSecret))
store.Options = &sessions.Options{
Path: config.ThreadrDir + "/",
MaxAge: 86400 * 30,
HttpOnly: true,
Secure: config.SessionSecure,
SameSite: http.SameSiteLaxMode,
}
if config.DomainName != "" {
store.Options.Domain = config.DomainName
}
app := &handlers.App{
DB: db,