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