github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/model/session/session.go (about)

     1  package session
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"net/http"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/cozy/cozy-stack/model/instance"
    11  	build "github.com/cozy/cozy-stack/pkg/config"
    12  	"github.com/cozy/cozy-stack/pkg/config/config"
    13  	"github.com/cozy/cozy-stack/pkg/consts"
    14  	"github.com/cozy/cozy-stack/pkg/couchdb"
    15  	"github.com/cozy/cozy-stack/pkg/crypto"
    16  	"github.com/cozy/cozy-stack/pkg/utils"
    17  	"github.com/labstack/echo/v4"
    18  )
    19  
    20  // SessionMaxAge is the maximum duration of the session in seconds
    21  const SessionMaxAge = 30 * 24 * time.Hour
    22  
    23  // defaultCookieName is name of the cookie created by cozy on nested subdomains
    24  const defaultCookieName = "cozysessid"
    25  
    26  var (
    27  	// ErrNoCookie is returned by GetSession if there is no cookie
    28  	ErrNoCookie = errors.New("No session cookie")
    29  	// ErrExpired is returned when the session has expired
    30  	ErrExpired = errors.New("Session expired")
    31  	// ErrInvalidID is returned by GetSession if the cookie contains wrong ID
    32  	ErrInvalidID = errors.New("Session cookie has wrong ID")
    33  )
    34  
    35  // A Session is an instance opened in a browser
    36  type Session struct {
    37  	instance  *instance.Instance
    38  	DocID     string    `json:"_id,omitempty"`
    39  	DocRev    string    `json:"_rev,omitempty"`
    40  	CreatedAt time.Time `json:"created_at"`
    41  	LastSeen  time.Time `json:"last_seen"`
    42  	LongRun   bool      `json:"long_run"`
    43  	ShortRun  bool      `json:"short_run"`
    44  }
    45  
    46  // DocType implements couchdb.Doc
    47  func (s *Session) DocType() string { return consts.Sessions }
    48  
    49  // ID implements couchdb.Doc
    50  func (s *Session) ID() string { return s.DocID }
    51  
    52  // SetID implements couchdb.Doc
    53  func (s *Session) SetID(v string) { s.DocID = v }
    54  
    55  // Rev implements couchdb.Doc
    56  func (s *Session) Rev() string { return s.DocRev }
    57  
    58  // SetRev implements couchdb.Doc
    59  func (s *Session) SetRev(v string) { s.DocRev = v }
    60  
    61  // Clone implements couchdb.Doc
    62  func (s *Session) Clone() couchdb.Doc {
    63  	cloned := *s
    64  	if cloned.instance != nil {
    65  		tmp := *s.instance
    66  		cloned.instance = &tmp
    67  	}
    68  	return &cloned
    69  }
    70  
    71  // ensure Session implements couchdb.Doc
    72  var _ couchdb.Doc = (*Session)(nil)
    73  
    74  // Duration is a type for the cookie expiration.
    75  type Duration int
    76  
    77  const (
    78  	// ShortRun is used for session that will last only 5 minutes. It is
    79  	// typically used for OAuth dance.
    80  	ShortRun Duration = iota
    81  	// NormalRun is used for a session that will expired when the browser is
    82  	// closed.
    83  	NormalRun
    84  	// LongRun is used to try to keep the session opened as long as possible.
    85  	LongRun
    86  )
    87  
    88  // Duration returns the session duration for the its cookie.
    89  func (s *Session) Duration() Duration {
    90  	if s.LongRun {
    91  		return LongRun
    92  	} else if s.ShortRun {
    93  		return ShortRun
    94  	}
    95  	return NormalRun
    96  }
    97  
    98  // OlderThan checks if a session last seen is older than t from now
    99  func (s *Session) OlderThan(t time.Duration) bool {
   100  	return time.Now().After(s.LastSeen.Add(t))
   101  }
   102  
   103  // New creates a session in couchdb for the given instance
   104  func New(i *instance.Instance, duration Duration) (*Session, error) {
   105  	now := time.Now()
   106  	s := &Session{
   107  		instance:  i,
   108  		LastSeen:  now,
   109  		CreatedAt: now,
   110  		ShortRun:  duration == ShortRun,
   111  		LongRun:   duration == LongRun,
   112  	}
   113  	if err := couchdb.CreateDoc(i, s); err != nil {
   114  		return nil, err
   115  	}
   116  	return s, nil
   117  }
   118  
   119  func lockSession(inst *instance.Instance, sessionID string) func() {
   120  	mu := config.Lock().ReadWrite(inst, "sessions/"+sessionID)
   121  	_ = mu.Lock()
   122  	return mu.Unlock
   123  }
   124  
   125  // Get fetches the session
   126  func Get(i *instance.Instance, sessionID string) (*Session, error) {
   127  	s := &Session{}
   128  	err := couchdb.GetDoc(i, consts.Sessions, sessionID, s)
   129  	if couchdb.IsNotFoundError(err) {
   130  		return nil, ErrInvalidID
   131  	}
   132  	if err != nil {
   133  		return nil, err
   134  	}
   135  	s.instance = i
   136  
   137  	// If the session is older than the session max age, it has expired and
   138  	// should be deleted.
   139  	if s.OlderThan(SessionMaxAge) {
   140  		defer lockSession(i, sessionID)()
   141  		err := couchdb.DeleteDoc(i, s)
   142  		if err != nil {
   143  			i.Logger().WithNamespace("loginaudit").
   144  				Warnf("Failed to delete expired session: %s", err)
   145  		} else {
   146  			i.Logger().WithNamespace("loginaudit").
   147  				Infof("Expired session deleted: %s", s.DocID)
   148  		}
   149  		return nil, ErrExpired
   150  	}
   151  
   152  	// In order to avoid too many updates of the session document, we have an
   153  	// update period of one day for the `last_seen` date, which is a good enough
   154  	// granularity.
   155  	if s.OlderThan(24 * time.Hour) {
   156  		defer lockSession(i, sessionID)()
   157  		lastSeen := s.LastSeen
   158  		s.LastSeen = time.Now()
   159  		err := couchdb.UpdateDoc(i, s)
   160  		if err != nil {
   161  			s.LastSeen = lastSeen
   162  		}
   163  	}
   164  
   165  	return s, nil
   166  }
   167  
   168  // CookieName returns the name of the cookie used for the given instance.
   169  func CookieName(i *instance.Instance) string {
   170  	if config.GetConfig().Subdomains == config.FlatSubdomains {
   171  		return "sess-" + i.DBPrefix()
   172  	}
   173  	return defaultCookieName
   174  }
   175  
   176  // CookieDomain returns the domain on which the cookie will be set. On nested
   177  // subdomains, the cookie is put on the domain of the instance, but for flat
   178  // subdomains, we need to put it one level higher (eg .mycozy.cloud instead of
   179  // .example.mycozy.cloud) to make the cookie available when the user visits
   180  // their apps.
   181  func CookieDomain(i *instance.Instance) string {
   182  	domain := i.ContextualDomain()
   183  	if config.GetConfig().Subdomains == config.FlatSubdomains {
   184  		parts := strings.SplitN(domain, ".", 2)
   185  		if len(parts) > 1 {
   186  			domain = parts[1]
   187  		}
   188  	}
   189  	return utils.CookieDomain("." + domain)
   190  }
   191  
   192  // FromCookie retrieves the session from a echo.Context cookies.
   193  func FromCookie(c echo.Context, i *instance.Instance) (*Session, error) {
   194  	cookie, err := c.Cookie(CookieName(i))
   195  	if err != nil || cookie.Value == "" {
   196  		return nil, ErrNoCookie
   197  	}
   198  
   199  	sessionID, err := crypto.DecodeAuthMessage(cookieSessionMACConfig(i), i.SessionSecret(),
   200  		[]byte(cookie.Value), nil)
   201  	if err != nil {
   202  		return nil, err
   203  	}
   204  
   205  	return Get(i, string(sessionID))
   206  }
   207  
   208  // GetAll returns all the active sessions
   209  func GetAll(inst *instance.Instance) ([]*Session, error) {
   210  	var sessions []*Session
   211  	req := couchdb.AllDocsRequest{
   212  		Limit: 50000,
   213  	}
   214  	if err := couchdb.GetAllDocs(inst, consts.Sessions, &req, &sessions); err != nil {
   215  		return nil, err
   216  	}
   217  	var expired []couchdb.Doc
   218  	kept := sessions[:0]
   219  	for _, sess := range sessions {
   220  		sess.instance = inst
   221  		if sess.OlderThan(SessionMaxAge) {
   222  			expired = append(expired, sess)
   223  		} else {
   224  			kept = append(kept, sess)
   225  		}
   226  	}
   227  	if len(expired) > 0 {
   228  		if err := couchdb.BulkDeleteDocs(inst, consts.Sessions, expired); err != nil {
   229  			inst.Logger().WithNamespace("sessions").
   230  				Infof("Error while deleting expired sessions: %s", err)
   231  		}
   232  	}
   233  	return kept, nil
   234  }
   235  
   236  // Delete is a function to delete the session in couchdb,
   237  // and returns a cookie with a negative MaxAge to clear it
   238  func (s *Session) Delete(i *instance.Instance) *http.Cookie {
   239  	err := couchdb.DeleteDoc(i, s)
   240  	if err != nil {
   241  		i.Logger().WithNamespace("loginaudit").
   242  			Errorf("Failed to delete session: %s", err)
   243  	} else {
   244  		i.Logger().WithNamespace("loginaudit").
   245  			Infof("Session deleted: %s", s.DocID)
   246  	}
   247  	return &http.Cookie{
   248  		Name:   CookieName(i),
   249  		Value:  "",
   250  		MaxAge: -1,
   251  		Path:   "/",
   252  		Domain: CookieDomain(i),
   253  	}
   254  }
   255  
   256  // ToCookie returns an http.Cookie for this Session
   257  func (s *Session) ToCookie() (*http.Cookie, error) {
   258  	inst := s.instance
   259  	encoded, err := crypto.EncodeAuthMessage(cookieSessionMACConfig(inst), inst.SessionSecret(), []byte(s.ID()), nil)
   260  	if err != nil {
   261  		return nil, err
   262  	}
   263  
   264  	maxAge := 0
   265  	if s.LongRun {
   266  		maxAge = 10 * 365 * 24 * 3600 // 10 years
   267  	} else if s.ShortRun {
   268  		maxAge = 5 * 60 // 5 minutes
   269  	}
   270  
   271  	return &http.Cookie{
   272  		Name:     CookieName(inst),
   273  		Value:    string(encoded),
   274  		MaxAge:   maxAge,
   275  		Path:     "/",
   276  		Domain:   CookieDomain(inst),
   277  		Secure:   !build.IsDevRelease(),
   278  		HttpOnly: true,
   279  		SameSite: http.SameSiteLaxMode,
   280  	}, nil
   281  }
   282  
   283  // DeleteOthers will remove all sessions except the one given in parameter.
   284  func DeleteOthers(i *instance.Instance, selfSessionID string) error {
   285  	var sessions []*Session
   286  	err := couchdb.ForeachDocs(i, consts.Sessions, func(_ string, data json.RawMessage) error {
   287  		var s Session
   288  		if err := json.Unmarshal(data, &s); err != nil {
   289  			return err
   290  		}
   291  		sessions = append(sessions, &s)
   292  		return nil
   293  	})
   294  	if err != nil {
   295  		return err
   296  	}
   297  	for _, s := range sessions {
   298  		if s.ID() != selfSessionID {
   299  			s.Delete(i)
   300  		}
   301  	}
   302  	return nil
   303  }
   304  
   305  // cookieSessionMACConfig returns the options to authenticate the session
   306  // cookie.
   307  //
   308  // We rely on a MACed cookie value, without additional encryption of the
   309  // message since it should not contain critical private informations and is
   310  // protected by HTTPs (secure cookie).
   311  //
   312  // About MaxLength, for a session of size 100 bytes
   313  //
   314  //	    8 bytes time
   315  //	+  32 bytes HMAC-SHA256
   316  //	+ 100 bytes session
   317  //	+ base64 encoding (4*n/3)
   318  //	< 200 bytes
   319  //
   320  // 256 bytes should be sufficient enough to support any type of session.
   321  func cookieSessionMACConfig(i *instance.Instance) crypto.MACConfig {
   322  	return crypto.MACConfig{
   323  		Name:   CookieName(i),
   324  		MaxLen: 256,
   325  	}
   326  }