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 }