Files
Gitea/modules/session/session.go
T
silverwind cf89ecd887 refactor!: move go-chi/session into Gitea (#39504)
The `gitea.com/go-chi/session` package only exists for Gitea, so it
moves into `modules/session` to fix its bugs directly. Fixes the flake
in
https://github.com/go-gitea/gitea/actions/runs/36726154500/job/109923538400.

- Sessions are only written back when changed, so a read-only request
can't revert a concurrent change or restore a logged-out session, like
https://github.com/go-macaron/session/commit/ae808a4a4660c802965c834299ab08f167effd12
- The session cookie is only set once a session holds data
- Every backend refreshes the expiry on load and file sessions are
written atomically
- Also fix  https://github.com/go-gitea/gitea/issues/36176

## ⚠️ BREAKING ⚠️

* the `mysql`, `postgres`, `couchbase` and `memcache` session providers
are removed, use `file`, `db` or `redis` instead
* login-related cookies are renamed to `gitea_session` and
`gitea_remember`, if you'd like to use the old names, set `COOKIE_NAME`
and `COOKIE_REMEMBER_NAME` in app.ini

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-10-02 21:08:14 +02:00

147 lines
4.4 KiB
Go

// Copyright 2013 Beego Authors
// Copyright 2014 The Macaron Authors
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package session
import (
"context"
"encoding/gob"
"fmt"
"net/http"
"time"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"gitea.dev/modules/util"
)
type backend interface {
load(sid string) ([]byte, error) // returns nil for missing or expired sessions, refreshes the expiry of others
save(sid string, data []byte, create bool) error // without create, only an existing session is updated
destroy(sid string) error
gc()
}
// CHI-SESSION-GOB-REGISTER: packages must gob.Register the types they store at startup, so data stored before a restart still decodes
func init() {
gob.Register([]any{})
gob.Register(map[int]any{})
gob.Register(map[string]any{})
gob.Register(map[any]any{})
gob.Register(map[string]string{})
gob.Register(map[int]string{})
gob.Register(map[int]int{})
gob.Register(map[int]int64{})
}
func newBackend(provider, config string, maxLifetime int64) (backend, error) {
switch provider {
case "memory":
return newMemoryBackend(maxLifetime), nil
case "file":
return newFileBackend(config, maxLifetime), nil
case "redis":
return newRedisBackend(config, maxLifetime)
case "db":
return &dbBackend{maxLifetime: maxLifetime}, nil
}
return nil, fmt.Errorf(`unsupported [session] PROVIDER %q, supported are "memory", "file", "redis" and "db", use "db" or "redis" to replace the removed "mysql", "postgres", "couchbase" and "memcache" providers`, provider)
}
func Sessioner() (func(next http.Handler) http.Handler, error) {
backend, err := newBackend(setting.SessionConfig.Provider, setting.SessionConfig.ProviderConfig, setting.SessionConfig.Maxlifetime)
if err != nil {
return nil, err
}
go runGC(graceful.GetManager().ShutdownContext(), backend, time.Duration(setting.SessionConfig.Gclifetime)*time.Second)
return func(next http.Handler) http.Handler {
return sessionHandler(backend, next)
}, nil
}
func sessionHandler(backend backend, next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
sess, err := startSession(backend, resp, req)
if err != nil {
log.Error("Unable to start session: %v", err)
resp.WriteHeader(http.StatusInternalServerError)
return
}
next.ServeHTTP(resp, req.WithContext(context.WithValue(req.Context(), ContextKey, sess)))
if err := sess.Release(); err != nil {
log.Error("Unable to release session: %v", err)
}
})
}
func startSession(backend backend, resp http.ResponseWriter, req *http.Request) (*store, error) {
sess := &store{backend: backend, resp: resp, data: map[any]any{}}
cookie, err := req.Cookie(setting.SessionConfig.CookieName)
if err != nil || !isValidSessionID(cookie.Value) {
sess.sid = newSessionID()
return sess, nil
}
sess.sid, sess.cookieSID = cookie.Value, cookie.Value
encoded, err := backend.load(sess.sid)
if err != nil {
return nil, err
}
if len(encoded) == 0 {
return sess, nil
}
var data map[any]any
if err := util.UnpackData(encoded, &data); err != nil {
log.Error("Unable to decode session data, starting with an empty session: %v", err)
sess.stored, sess.changed = true, true
} else if len(data) > 0 {
sess.data, sess.stored = data, true
}
return sess, nil
}
func newSessionID() string {
// lower case (in case the file system is case-insensitive) and length=16 (db session's primary key is fixed size 16)
// the entropy is about 36^16 > 80 bits
return util.FastCryptoRandomString(16, "abcdefghijklmnopqrstuvwxyz0123456789")
}
func isValidSessionID(sid string) bool {
if len(sid) != 16 { // db session has a primary key with fixed size 16
return false
}
for i := range len(sid) {
c := sid[i]
valid := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z')
if !valid {
return false
}
}
return true
}
func newCookie(value string) *http.Cookie {
return &http.Cookie{
Name: setting.SessionConfig.CookieName,
Value: value,
Path: util.IfZero(setting.SessionConfig.CookiePath, "/"),
Domain: setting.SessionConfig.Domain,
Secure: setting.SessionConfig.Secure,
HttpOnly: true,
SameSite: setting.SessionConfig.SameSite,
}
}
func runGC(ctx context.Context, backend backend, interval time.Duration) {
for {
backend.gc()
select {
case <-ctx.Done():
return
case <-time.After(interval):
}
}
}