session.go 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. package appproxy
  2. import (
  3. "crypto/rand"
  4. "encoding/hex"
  5. "sync"
  6. "time"
  7. )
  8. /*
  9. session.go
  10. Login hand-off for subdomain mode. The ArozOS session cookie is host-only,
  11. so an app on its own hostname cannot see it. Instead:
  12. 1. /app/<slug>/ on the ArozOS host (where the user is logged in) issues a
  13. short lived one-time ticket bound to the user and the endpoint, and
  14. redirects to https://<hostname>/__appproxy/claim?t=<ticket>
  15. 2. The app host exchanges the ticket for its own session cookie.
  16. Sessions live in memory: after a restart the next navigation silently
  17. repeats the hand-off while the user is still logged in to ArozOS.
  18. */
  19. const (
  20. ticketTTL = 60 * time.Second
  21. sessionTTL = 12 * time.Hour
  22. //How long a session trusts its cached user record before looking it up again
  23. identityRecheck = time.Minute
  24. )
  25. type ticket struct {
  26. username string
  27. slug string
  28. returnTo string
  29. expires time.Time
  30. }
  31. type session struct {
  32. username string
  33. slug string
  34. expires time.Time
  35. checkedAt time.Time
  36. identity *Identity
  37. }
  38. type sessionStore struct {
  39. mu sync.Mutex
  40. tickets map[string]*ticket
  41. sessions map[string]*session
  42. lastSweep time.Time
  43. }
  44. func newSessionStore() *sessionStore {
  45. return &sessionStore{
  46. tickets: map[string]*ticket{},
  47. sessions: map[string]*session{},
  48. }
  49. }
  50. func randomToken() string {
  51. b := make([]byte, 32)
  52. if _, err := rand.Read(b); err != nil {
  53. panic("appproxy: no system randomness: " + err.Error())
  54. }
  55. return hex.EncodeToString(b)
  56. }
  57. // sweep drops expired entries, at most once a minute. Caller holds the lock
  58. func (s *sessionStore) sweep(now time.Time) {
  59. if now.Sub(s.lastSweep) < time.Minute {
  60. return
  61. }
  62. s.lastSweep = now
  63. for k, t := range s.tickets {
  64. if now.After(t.expires) {
  65. delete(s.tickets, k)
  66. }
  67. }
  68. for k, ss := range s.sessions {
  69. if now.After(ss.expires) {
  70. delete(s.sessions, k)
  71. }
  72. }
  73. }
  74. // issueTicket creates a one-time ticket for a user to enter an endpoint
  75. func (s *sessionStore) issueTicket(username string, slug string, returnTo string) string {
  76. now := time.Now()
  77. token := randomToken()
  78. s.mu.Lock()
  79. defer s.mu.Unlock()
  80. s.sweep(now)
  81. s.tickets[token] = &ticket{username: username, slug: slug, returnTo: returnTo, expires: now.Add(ticketTTL)}
  82. return token
  83. }
  84. // claimTicket consumes a ticket for the endpoint and opens a session. It
  85. // returns the session token and the path to continue to
  86. func (s *sessionStore) claimTicket(token string, slug string) (string, string, bool) {
  87. now := time.Now()
  88. s.mu.Lock()
  89. defer s.mu.Unlock()
  90. t, ok := s.tickets[token]
  91. if !ok {
  92. return "", "", false
  93. }
  94. //One-time: consumed even when it does not match
  95. delete(s.tickets, token)
  96. if t.slug != slug || now.After(t.expires) {
  97. return "", "", false
  98. }
  99. sessionToken := randomToken()
  100. s.sessions[sessionToken] = &session{username: t.username, slug: slug, expires: now.Add(sessionTTL)}
  101. return sessionToken, t.returnTo, true
  102. }
  103. // resolve returns the user of a session cookie on an endpoint, revalidating
  104. // the account through lookup at most once a minute
  105. func (s *sessionStore) resolve(token string, slug string, lookup func(string) *Identity) *Identity {
  106. if token == "" {
  107. return nil
  108. }
  109. now := time.Now()
  110. s.mu.Lock()
  111. ss, ok := s.sessions[token]
  112. if !ok || ss.slug != slug || now.After(ss.expires) {
  113. if ok {
  114. delete(s.sessions, token)
  115. }
  116. s.mu.Unlock()
  117. return nil
  118. }
  119. if ss.identity != nil && now.Sub(ss.checkedAt) < identityRecheck {
  120. id := ss.identity
  121. s.mu.Unlock()
  122. return id
  123. }
  124. username := ss.username
  125. s.mu.Unlock()
  126. //Look the account up outside the lock, it may hit the database
  127. id := lookup(username)
  128. s.mu.Lock()
  129. defer s.mu.Unlock()
  130. if id == nil {
  131. //Account removed
  132. delete(s.sessions, token)
  133. return nil
  134. }
  135. if ss, ok := s.sessions[token]; ok {
  136. ss.identity = id
  137. ss.checkedAt = now
  138. }
  139. return id
  140. }
  141. // revoke ends one session
  142. func (s *sessionStore) revoke(token string) {
  143. s.mu.Lock()
  144. defer s.mu.Unlock()
  145. delete(s.sessions, token)
  146. }
  147. // dropEndpoint ends every session and ticket of an endpoint
  148. func (s *sessionStore) dropEndpoint(slug string) {
  149. s.mu.Lock()
  150. defer s.mu.Unlock()
  151. for k, t := range s.tickets {
  152. if t.slug == slug {
  153. delete(s.tickets, k)
  154. }
  155. }
  156. for k, ss := range s.sessions {
  157. if ss.slug == slug {
  158. delete(s.sessions, k)
  159. }
  160. }
  161. }