auth
auth/session.go
Introduced scopes. Created a Scope type and a scopeStore interface, along with the memstore methods for the scopeStore. This will allow applications to define access with granularity, so users can grant access to some data, not _all_ data. We're operating on the assumption that there won't be an unreasonable number of scopes defined, so there is no paging operation included for the ListScopes method. This is a decision that may have to be revisited in the future, depending on usecases.
| paddy@70 | 1 package auth |
| paddy@70 | 2 |
| paddy@70 | 3 import ( |
| paddy@132 | 4 "crypto/rand" |
| paddy@98 | 5 "crypto/sha256" |
| paddy@132 | 6 "encoding/base64" |
| paddy@98 | 7 "encoding/hex" |
| paddy@119 | 8 "encoding/json" |
| paddy@70 | 9 "errors" |
| paddy@98 | 10 "log" |
| paddy@98 | 11 "net/http" |
| paddy@89 | 12 "sort" |
| paddy@70 | 13 "time" |
| paddy@70 | 14 |
| paddy@107 | 15 "code.secondbit.org/pass.hg" |
| paddy@107 | 16 "code.secondbit.org/uuid.hg" |
| paddy@98 | 17 "github.com/gorilla/mux" |
| paddy@98 | 18 ) |
| paddy@98 | 19 |
| paddy@98 | 20 const ( |
| paddy@132 | 21 authCookieName = "auth" |
| paddy@98 | 22 loginTemplateName = "login" |
| paddy@70 | 23 ) |
| paddy@70 | 24 |
| paddy@119 | 25 func init() { |
| paddy@119 | 26 RegisterGrantType("password", GrantType{ |
| paddy@119 | 27 Validate: credentialsValidate, |
| paddy@119 | 28 Invalidate: nil, |
| paddy@119 | 29 IssuesRefresh: true, |
| paddy@119 | 30 ReturnToken: RenderJSONToken, |
| paddy@124 | 31 AuditString: credentialsAuditString, |
| paddy@119 | 32 }) |
| paddy@119 | 33 } |
| paddy@119 | 34 |
| paddy@70 | 35 var ( |
| paddy@70 | 36 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first. |
| paddy@70 | 37 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context") |
| paddy@70 | 38 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore. |
| paddy@70 | 39 ErrSessionNotFound = errors.New("session not found in sessionStore") |
| paddy@70 | 40 // ErrInvalidSession is returned when a Session is specified but is not valid. |
| paddy@70 | 41 ErrInvalidSession = errors.New("session is not valid") |
| paddy@77 | 42 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore. |
| paddy@77 | 43 ErrSessionAlreadyExists = errors.New("session already exists") |
| paddy@132 | 44 // ErrCSRFAttempt is returned when a CSRF attempt is detected. |
| paddy@132 | 45 ErrCSRFAttempt = errors.New("CSRF attempt") |
| paddy@98 | 46 |
| paddy@98 | 47 passphraseSchemes = map[int]passphraseScheme{ |
| paddy@98 | 48 1: { |
| paddy@98 | 49 check: pbkdf2sha256check, |
| paddy@98 | 50 create: pbkdf2sha256create, |
| paddy@98 | 51 calculateIterations: pbkdf2sha256calc, |
| paddy@98 | 52 }, |
| paddy@98 | 53 } |
| paddy@70 | 54 ) |
| paddy@70 | 55 |
| paddy@98 | 56 type passphraseScheme struct { |
| paddy@98 | 57 check func(profile Profile, passphrase string) (bool, error) |
| paddy@103 | 58 create func(passphrase string, iterations int) (result, salt string, err error) |
| paddy@98 | 59 calculateIterations func() (int, error) |
| paddy@98 | 60 } |
| paddy@98 | 61 |
| paddy@70 | 62 // Session represents a user's authenticated session, associating it with a profile |
| paddy@70 | 63 // and some audit data. |
| paddy@70 | 64 type Session struct { |
| paddy@70 | 65 ID string |
| paddy@70 | 66 IP string |
| paddy@70 | 67 UserAgent string |
| paddy@70 | 68 ProfileID uuid.ID |
| paddy@98 | 69 Login string |
| paddy@70 | 70 Created time.Time |
| paddy@132 | 71 Expires time.Time |
| paddy@70 | 72 Active bool |
| paddy@132 | 73 CSRFToken string |
| paddy@70 | 74 } |
| paddy@70 | 75 |
| paddy@89 | 76 type sortedSessions []Session |
| paddy@89 | 77 |
| paddy@89 | 78 func (s sortedSessions) Len() int { |
| paddy@89 | 79 return len(s) |
| paddy@89 | 80 } |
| paddy@89 | 81 |
| paddy@89 | 82 func (s sortedSessions) Less(i, j int) bool { |
| paddy@89 | 83 return s[i].Created.After(s[j].Created) |
| paddy@89 | 84 } |
| paddy@89 | 85 |
| paddy@89 | 86 func (s sortedSessions) Swap(i, j int) { |
| paddy@89 | 87 s[i], s[j] = s[j], s[i] |
| paddy@89 | 88 } |
| paddy@89 | 89 |
| paddy@70 | 90 type sessionStore interface { |
| paddy@70 | 91 createSession(session Session) error |
| paddy@70 | 92 getSession(id string) (Session, error) |
| paddy@70 | 93 removeSession(id string) error |
| paddy@70 | 94 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) |
| paddy@70 | 95 } |
| paddy@77 | 96 |
| paddy@77 | 97 func (m *memstore) createSession(session Session) error { |
| paddy@77 | 98 m.sessionLock.Lock() |
| paddy@77 | 99 defer m.sessionLock.Unlock() |
| paddy@77 | 100 if _, ok := m.sessions[session.ID]; ok { |
| paddy@77 | 101 return ErrSessionAlreadyExists |
| paddy@77 | 102 } |
| paddy@77 | 103 m.sessions[session.ID] = session |
| paddy@77 | 104 return nil |
| paddy@77 | 105 } |
| paddy@77 | 106 |
| paddy@77 | 107 func (m *memstore) getSession(id string) (Session, error) { |
| paddy@77 | 108 m.sessionLock.RLock() |
| paddy@77 | 109 defer m.sessionLock.RUnlock() |
| paddy@77 | 110 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 111 return Session{}, ErrSessionNotFound |
| paddy@77 | 112 } |
| paddy@77 | 113 return m.sessions[id], nil |
| paddy@77 | 114 } |
| paddy@77 | 115 |
| paddy@77 | 116 func (m *memstore) removeSession(id string) error { |
| paddy@77 | 117 m.sessionLock.Lock() |
| paddy@77 | 118 defer m.sessionLock.Unlock() |
| paddy@77 | 119 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 120 return ErrSessionNotFound |
| paddy@77 | 121 } |
| paddy@77 | 122 delete(m.sessions, id) |
| paddy@77 | 123 return nil |
| paddy@77 | 124 } |
| paddy@77 | 125 |
| paddy@77 | 126 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) { |
| paddy@77 | 127 m.sessionLock.RLock() |
| paddy@77 | 128 defer m.sessionLock.RUnlock() |
| paddy@77 | 129 res := []Session{} |
| paddy@77 | 130 for _, session := range m.sessions { |
| paddy@77 | 131 if int64(len(res)) >= num { |
| paddy@77 | 132 break |
| paddy@77 | 133 } |
| paddy@77 | 134 if profile != nil && !profile.Equal(session.ProfileID) { |
| paddy@77 | 135 continue |
| paddy@77 | 136 } |
| paddy@77 | 137 if !before.IsZero() && session.Created.After(before) { |
| paddy@77 | 138 continue |
| paddy@77 | 139 } |
| paddy@77 | 140 res = append(res, session) |
| paddy@77 | 141 } |
| paddy@89 | 142 sorted := sortedSessions(res) |
| paddy@89 | 143 sort.Sort(sorted) |
| paddy@89 | 144 res = []Session(sorted) |
| paddy@77 | 145 return res, nil |
| paddy@77 | 146 } |
| paddy@98 | 147 |
| paddy@98 | 148 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout. |
| paddy@98 | 149 func RegisterSessionHandlers(r *mux.Router, context Context) { |
| paddy@98 | 150 r.Handle("/login", wrap(context, CreateSessionHandler)) |
| paddy@128 | 151 // BUG(paddy): We need to implement a handler for listing sessions active on a profile. |
| paddy@128 | 152 // BUG(paddy): We need to implement a handler for terminating sessions. |
| paddy@98 | 153 } |
| paddy@98 | 154 |
| paddy@132 | 155 func checkCSRF(r *http.Request, s Session) error { |
| paddy@132 | 156 if r.PostFormValue("csrftoken") != s.CSRFToken { |
| paddy@132 | 157 return ErrCSRFAttempt |
| paddy@132 | 158 } |
| paddy@132 | 159 return nil |
| paddy@132 | 160 } |
| paddy@132 | 161 |
| paddy@98 | 162 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@98 | 163 cookie, err := r.Cookie(authCookieName) |
| paddy@98 | 164 if err == http.ErrNoCookie { |
| paddy@98 | 165 return Session{}, ErrNoSession |
| paddy@98 | 166 } else if err != nil { |
| paddy@98 | 167 log.Println(err) |
| paddy@98 | 168 return Session{}, err |
| paddy@98 | 169 } |
| paddy@98 | 170 sess, err := context.GetSession(cookie.Value) |
| paddy@98 | 171 if err == ErrSessionNotFound { |
| paddy@98 | 172 return Session{}, ErrInvalidSession |
| paddy@98 | 173 } else if err != nil { |
| paddy@98 | 174 return Session{}, err |
| paddy@98 | 175 } |
| paddy@98 | 176 if !sess.Active { |
| paddy@98 | 177 return Session{}, ErrInvalidSession |
| paddy@98 | 178 } |
| paddy@132 | 179 if time.Now().After(sess.Expires) { |
| paddy@132 | 180 return Session{}, ErrInvalidSession |
| paddy@132 | 181 } |
| paddy@98 | 182 return sess, nil |
| paddy@98 | 183 } |
| paddy@98 | 184 |
| paddy@98 | 185 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@98 | 186 if context.loginURI == nil { |
| paddy@98 | 187 return "" |
| paddy@98 | 188 } |
| paddy@98 | 189 uri := *context.loginURI |
| paddy@98 | 190 q := uri.Query() |
| paddy@98 | 191 q.Set("from", r.URL.String()) |
| paddy@98 | 192 uri.RawQuery = q.Encode() |
| paddy@98 | 193 return uri.String() |
| paddy@98 | 194 } |
| paddy@98 | 195 |
| paddy@98 | 196 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) { |
| paddy@98 | 197 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@98 | 198 if err != nil { |
| paddy@98 | 199 return false, err |
| paddy@98 | 200 } |
| paddy@103 | 201 realSalt, err := hex.DecodeString(profile.Salt) |
| paddy@103 | 202 if err != nil { |
| paddy@103 | 203 return false, err |
| paddy@103 | 204 } |
| paddy@103 | 205 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt)) |
| paddy@98 | 206 if !pass.Compare(candidate, realPass) { |
| paddy@98 | 207 return false, ErrIncorrectAuth |
| paddy@98 | 208 } |
| paddy@98 | 209 return true, nil |
| paddy@98 | 210 } |
| paddy@98 | 211 |
| paddy@103 | 212 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) { |
| paddy@103 | 213 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase)) |
| paddy@103 | 214 if err != nil { |
| paddy@103 | 215 return "", "", err |
| paddy@103 | 216 } |
| paddy@103 | 217 result = hex.EncodeToString(passBytes) |
| paddy@103 | 218 salt = hex.EncodeToString(saltBytes) |
| paddy@103 | 219 return result, salt, err |
| paddy@98 | 220 } |
| paddy@98 | 221 |
| paddy@98 | 222 func pbkdf2sha256calc() (int, error) { |
| paddy@98 | 223 return pass.CalculateIterations(sha256.New) |
| paddy@98 | 224 } |
| paddy@98 | 225 |
| paddy@98 | 226 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@98 | 227 profile, err := context.GetProfileByLogin(user) |
| paddy@98 | 228 if err != nil { |
| paddy@98 | 229 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@98 | 230 return Profile{}, ErrIncorrectAuth |
| paddy@98 | 231 } |
| paddy@98 | 232 return Profile{}, err |
| paddy@98 | 233 } |
| paddy@98 | 234 if profile.Compromised { |
| paddy@98 | 235 return Profile{}, ErrProfileCompromised |
| paddy@98 | 236 } |
| paddy@98 | 237 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) { |
| paddy@98 | 238 return profile, ErrProfileLocked |
| paddy@98 | 239 } |
| paddy@98 | 240 scheme, ok := passphraseSchemes[profile.PassphraseScheme] |
| paddy@98 | 241 if !ok { |
| paddy@98 | 242 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@98 | 243 } |
| paddy@98 | 244 result, err := scheme.check(profile, passphrase) |
| paddy@98 | 245 if !result { |
| paddy@98 | 246 return Profile{}, err |
| paddy@98 | 247 } |
| paddy@98 | 248 return profile, nil |
| paddy@98 | 249 } |
| paddy@98 | 250 |
| paddy@98 | 251 // CreateSessionHandler allows the user to log into their account and create their session. |
| paddy@98 | 252 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@98 | 253 errors := []error{} |
| paddy@98 | 254 if r.Method == "POST" { |
| paddy@98 | 255 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context) |
| paddy@98 | 256 if err == nil { |
| paddy@98 | 257 ip := r.Header.Get("X-Forwarded-For") |
| paddy@98 | 258 if ip == "" { |
| paddy@98 | 259 ip = r.RemoteAddr |
| paddy@98 | 260 } |
| paddy@132 | 261 sessionID := make([]byte, 32) |
| paddy@132 | 262 csrfToken := make([]byte, 32) |
| paddy@132 | 263 _, err = rand.Read(sessionID) |
| paddy@132 | 264 if err != nil { |
| paddy@132 | 265 log.Println("Error reading CSPRNG for session ID:", err) |
| paddy@132 | 266 w.WriteHeader(http.StatusInternalServerError) |
| paddy@132 | 267 w.Write([]byte("Internal error")) |
| paddy@132 | 268 return |
| paddy@132 | 269 } |
| paddy@132 | 270 _, err = rand.Read(csrfToken) |
| paddy@132 | 271 if err != nil { |
| paddy@132 | 272 log.Println("Error reading CSPRNG for CSRF token:", err) |
| paddy@132 | 273 w.WriteHeader(http.StatusInternalServerError) |
| paddy@132 | 274 w.Write([]byte("internal error")) |
| paddy@132 | 275 return |
| paddy@132 | 276 } |
| paddy@98 | 277 session := Session{ |
| paddy@132 | 278 ID: base64.StdEncoding.EncodeToString(sessionID), |
| paddy@98 | 279 IP: ip, |
| paddy@98 | 280 UserAgent: r.UserAgent(), |
| paddy@98 | 281 ProfileID: profile.ID, |
| paddy@98 | 282 Login: r.PostFormValue("login"), |
| paddy@98 | 283 Created: time.Now(), |
| paddy@132 | 284 Expires: time.Now().Add(time.Hour), |
| paddy@98 | 285 Active: true, |
| paddy@132 | 286 CSRFToken: base64.StdEncoding.EncodeToString(csrfToken), |
| paddy@98 | 287 } |
| paddy@98 | 288 err = context.CreateSession(session) |
| paddy@98 | 289 if err != nil { |
| paddy@98 | 290 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 291 w.Write([]byte(err.Error())) |
| paddy@98 | 292 return |
| paddy@98 | 293 } |
| paddy@132 | 294 // BUG(paddy): We really need to do a security audit on our cookies. |
| paddy@98 | 295 cookie := http.Cookie{ |
| paddy@98 | 296 Name: authCookieName, |
| paddy@98 | 297 Value: session.ID, |
| paddy@132 | 298 Expires: session.Expires, |
| paddy@98 | 299 HttpOnly: true, |
| paddy@132 | 300 Secure: context.config.secureCookie, |
| paddy@98 | 301 } |
| paddy@98 | 302 http.SetCookie(w, &cookie) |
| paddy@98 | 303 redirectTo := r.URL.Query().Get("from") |
| paddy@98 | 304 if redirectTo == "" { |
| paddy@98 | 305 redirectTo = "/" |
| paddy@98 | 306 } |
| paddy@98 | 307 http.Redirect(w, r, redirectTo, http.StatusFound) |
| paddy@98 | 308 return |
| paddy@98 | 309 } else if err != ErrIncorrectAuth && err != ErrProfileCompromised && err != ErrProfileLocked { |
| paddy@98 | 310 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 311 w.Write([]byte(err.Error())) |
| paddy@98 | 312 return |
| paddy@98 | 313 } else { |
| paddy@98 | 314 errors = append(errors, err) |
| paddy@98 | 315 } |
| paddy@98 | 316 } |
| paddy@98 | 317 context.Render(w, loginTemplateName, map[string]interface{}{ |
| paddy@98 | 318 "errors": errors, |
| paddy@98 | 319 }) |
| paddy@98 | 320 } |
| paddy@119 | 321 |
| paddy@119 | 322 func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scope string, profileID uuid.ID, valid bool) { |
| paddy@119 | 323 enc := json.NewEncoder(w) |
| paddy@119 | 324 username := r.PostFormValue("username") |
| paddy@119 | 325 password := r.PostFormValue("password") |
| paddy@119 | 326 scope = r.PostFormValue("scope") |
| paddy@119 | 327 profile, err := authenticate(username, password, context) |
| paddy@119 | 328 if err != nil { |
| paddy@119 | 329 if err == ErrIncorrectAuth || err == ErrProfileCompromised || err == ErrProfileLocked { |
| paddy@119 | 330 w.WriteHeader(http.StatusBadRequest) |
| paddy@119 | 331 renderJSONError(enc, "invalid_grant") |
| paddy@119 | 332 return |
| paddy@119 | 333 } |
| paddy@119 | 334 w.WriteHeader(http.StatusInternalServerError) |
| paddy@119 | 335 w.Write([]byte(err.Error())) |
| paddy@119 | 336 return |
| paddy@119 | 337 } |
| paddy@119 | 338 profileID = profile.ID |
| paddy@119 | 339 valid = true |
| paddy@119 | 340 return |
| paddy@119 | 341 } |
| paddy@124 | 342 |
| paddy@124 | 343 func credentialsAuditString(r *http.Request) string { |
| paddy@124 | 344 return "credentials" |
| paddy@124 | 345 } |