auth
auth/session.go
Refactor verifyClient, implement refresh tokens. Refactor verifyClient into verifyClient and getClientAuth. We moved verifyClient out of each of the GrantType's validation functions and into the access token endpoint, where it will be called before the GrantType's validation function. Yay, less code repetition. And seeing as we always want to verify the client, that seems like a good way to prevent things like 118a69954621 from happening. This did, however, force us to add an AllowsPublic property to the GrantType, so the token endpoint knows whether or not a public Client is valid for any given GrantType. We also implemented the refresh token grant type, which required adding ClientID and RefreshRevoked as properties on the Token type. We need ClientID because we need to constrain refresh tokens to the client that issued them. We also should probably keep track of which tokens belong to which clients, just as a general rule of thumb. RefreshRevoked had to be created, next to Revoked, because the AccessToken could be revoked and the RefreshToken still valid, or vice versa. Notably, when you issue a new refresh token, the old one is revoked, but the access token is still valid. It remains to be seen whether this is a good way to track things or not. The number of duplicated properties lead me to believe our type is not a great representation of the underlying concepts.
| paddy@70 | 1 package auth |
| paddy@70 | 2 |
| paddy@70 | 3 import ( |
| paddy@98 | 4 "crypto/sha256" |
| paddy@98 | 5 "encoding/hex" |
| paddy@119 | 6 "encoding/json" |
| paddy@70 | 7 "errors" |
| paddy@98 | 8 "log" |
| paddy@98 | 9 "net/http" |
| paddy@89 | 10 "sort" |
| paddy@70 | 11 "time" |
| paddy@70 | 12 |
| paddy@107 | 13 "code.secondbit.org/pass.hg" |
| paddy@107 | 14 "code.secondbit.org/uuid.hg" |
| paddy@98 | 15 "github.com/gorilla/mux" |
| paddy@98 | 16 ) |
| paddy@98 | 17 |
| paddy@98 | 18 const ( |
| paddy@98 | 19 loginTemplateName = "login" |
| paddy@70 | 20 ) |
| paddy@70 | 21 |
| paddy@119 | 22 func init() { |
| paddy@119 | 23 RegisterGrantType("password", GrantType{ |
| paddy@119 | 24 Validate: credentialsValidate, |
| paddy@119 | 25 Invalidate: nil, |
| paddy@119 | 26 IssuesRefresh: true, |
| paddy@119 | 27 ReturnToken: RenderJSONToken, |
| paddy@119 | 28 }) |
| paddy@119 | 29 } |
| paddy@119 | 30 |
| paddy@70 | 31 var ( |
| paddy@70 | 32 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first. |
| paddy@70 | 33 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context") |
| paddy@70 | 34 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore. |
| paddy@70 | 35 ErrSessionNotFound = errors.New("session not found in sessionStore") |
| paddy@70 | 36 // ErrInvalidSession is returned when a Session is specified but is not valid. |
| paddy@70 | 37 ErrInvalidSession = errors.New("session is not valid") |
| paddy@77 | 38 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore. |
| paddy@77 | 39 ErrSessionAlreadyExists = errors.New("session already exists") |
| paddy@98 | 40 |
| paddy@98 | 41 passphraseSchemes = map[int]passphraseScheme{ |
| paddy@98 | 42 1: { |
| paddy@98 | 43 check: pbkdf2sha256check, |
| paddy@98 | 44 create: pbkdf2sha256create, |
| paddy@98 | 45 calculateIterations: pbkdf2sha256calc, |
| paddy@98 | 46 }, |
| paddy@98 | 47 } |
| paddy@70 | 48 ) |
| paddy@70 | 49 |
| paddy@98 | 50 type passphraseScheme struct { |
| paddy@98 | 51 check func(profile Profile, passphrase string) (bool, error) |
| paddy@103 | 52 create func(passphrase string, iterations int) (result, salt string, err error) |
| paddy@98 | 53 calculateIterations func() (int, error) |
| paddy@98 | 54 } |
| paddy@98 | 55 |
| paddy@70 | 56 // Session represents a user's authenticated session, associating it with a profile |
| paddy@70 | 57 // and some audit data. |
| paddy@70 | 58 type Session struct { |
| paddy@70 | 59 ID string |
| paddy@70 | 60 IP string |
| paddy@70 | 61 UserAgent string |
| paddy@70 | 62 ProfileID uuid.ID |
| paddy@98 | 63 Login string |
| paddy@70 | 64 Created time.Time |
| paddy@70 | 65 Active bool |
| paddy@70 | 66 } |
| paddy@70 | 67 |
| paddy@89 | 68 type sortedSessions []Session |
| paddy@89 | 69 |
| paddy@89 | 70 func (s sortedSessions) Len() int { |
| paddy@89 | 71 return len(s) |
| paddy@89 | 72 } |
| paddy@89 | 73 |
| paddy@89 | 74 func (s sortedSessions) Less(i, j int) bool { |
| paddy@89 | 75 return s[i].Created.After(s[j].Created) |
| paddy@89 | 76 } |
| paddy@89 | 77 |
| paddy@89 | 78 func (s sortedSessions) Swap(i, j int) { |
| paddy@89 | 79 s[i], s[j] = s[j], s[i] |
| paddy@89 | 80 } |
| paddy@89 | 81 |
| paddy@70 | 82 type sessionStore interface { |
| paddy@70 | 83 createSession(session Session) error |
| paddy@70 | 84 getSession(id string) (Session, error) |
| paddy@70 | 85 removeSession(id string) error |
| paddy@70 | 86 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) |
| paddy@70 | 87 } |
| paddy@77 | 88 |
| paddy@77 | 89 func (m *memstore) createSession(session Session) error { |
| paddy@77 | 90 m.sessionLock.Lock() |
| paddy@77 | 91 defer m.sessionLock.Unlock() |
| paddy@77 | 92 if _, ok := m.sessions[session.ID]; ok { |
| paddy@77 | 93 return ErrSessionAlreadyExists |
| paddy@77 | 94 } |
| paddy@77 | 95 m.sessions[session.ID] = session |
| paddy@77 | 96 return nil |
| paddy@77 | 97 } |
| paddy@77 | 98 |
| paddy@77 | 99 func (m *memstore) getSession(id string) (Session, error) { |
| paddy@77 | 100 m.sessionLock.RLock() |
| paddy@77 | 101 defer m.sessionLock.RUnlock() |
| paddy@77 | 102 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 103 return Session{}, ErrSessionNotFound |
| paddy@77 | 104 } |
| paddy@77 | 105 return m.sessions[id], nil |
| paddy@77 | 106 } |
| paddy@77 | 107 |
| paddy@77 | 108 func (m *memstore) removeSession(id string) error { |
| paddy@77 | 109 m.sessionLock.Lock() |
| paddy@77 | 110 defer m.sessionLock.Unlock() |
| paddy@77 | 111 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 112 return ErrSessionNotFound |
| paddy@77 | 113 } |
| paddy@77 | 114 delete(m.sessions, id) |
| paddy@77 | 115 return nil |
| paddy@77 | 116 } |
| paddy@77 | 117 |
| paddy@77 | 118 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) { |
| paddy@77 | 119 m.sessionLock.RLock() |
| paddy@77 | 120 defer m.sessionLock.RUnlock() |
| paddy@77 | 121 res := []Session{} |
| paddy@77 | 122 for _, session := range m.sessions { |
| paddy@77 | 123 if int64(len(res)) >= num { |
| paddy@77 | 124 break |
| paddy@77 | 125 } |
| paddy@77 | 126 if profile != nil && !profile.Equal(session.ProfileID) { |
| paddy@77 | 127 continue |
| paddy@77 | 128 } |
| paddy@77 | 129 if !before.IsZero() && session.Created.After(before) { |
| paddy@77 | 130 continue |
| paddy@77 | 131 } |
| paddy@77 | 132 res = append(res, session) |
| paddy@77 | 133 } |
| paddy@89 | 134 sorted := sortedSessions(res) |
| paddy@89 | 135 sort.Sort(sorted) |
| paddy@89 | 136 res = []Session(sorted) |
| paddy@77 | 137 return res, nil |
| paddy@77 | 138 } |
| paddy@98 | 139 |
| paddy@98 | 140 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout. |
| paddy@98 | 141 func RegisterSessionHandlers(r *mux.Router, context Context) { |
| paddy@98 | 142 r.Handle("/login", wrap(context, CreateSessionHandler)) |
| paddy@98 | 143 } |
| paddy@98 | 144 |
| paddy@98 | 145 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@98 | 146 cookie, err := r.Cookie(authCookieName) |
| paddy@98 | 147 if err == http.ErrNoCookie { |
| paddy@98 | 148 return Session{}, ErrNoSession |
| paddy@98 | 149 } else if err != nil { |
| paddy@98 | 150 log.Println(err) |
| paddy@98 | 151 return Session{}, err |
| paddy@98 | 152 } |
| paddy@98 | 153 sess, err := context.GetSession(cookie.Value) |
| paddy@98 | 154 if err == ErrSessionNotFound { |
| paddy@98 | 155 return Session{}, ErrInvalidSession |
| paddy@98 | 156 } else if err != nil { |
| paddy@98 | 157 return Session{}, err |
| paddy@98 | 158 } |
| paddy@98 | 159 if !sess.Active { |
| paddy@98 | 160 return Session{}, ErrInvalidSession |
| paddy@98 | 161 } |
| paddy@98 | 162 return sess, nil |
| paddy@98 | 163 } |
| paddy@98 | 164 |
| paddy@98 | 165 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@98 | 166 if context.loginURI == nil { |
| paddy@98 | 167 return "" |
| paddy@98 | 168 } |
| paddy@98 | 169 uri := *context.loginURI |
| paddy@98 | 170 q := uri.Query() |
| paddy@98 | 171 q.Set("from", r.URL.String()) |
| paddy@98 | 172 uri.RawQuery = q.Encode() |
| paddy@98 | 173 return uri.String() |
| paddy@98 | 174 } |
| paddy@98 | 175 |
| paddy@98 | 176 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) { |
| paddy@98 | 177 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@98 | 178 if err != nil { |
| paddy@98 | 179 return false, err |
| paddy@98 | 180 } |
| paddy@103 | 181 realSalt, err := hex.DecodeString(profile.Salt) |
| paddy@103 | 182 if err != nil { |
| paddy@103 | 183 return false, err |
| paddy@103 | 184 } |
| paddy@103 | 185 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(realSalt)) |
| paddy@98 | 186 if !pass.Compare(candidate, realPass) { |
| paddy@98 | 187 return false, ErrIncorrectAuth |
| paddy@98 | 188 } |
| paddy@98 | 189 return true, nil |
| paddy@98 | 190 } |
| paddy@98 | 191 |
| paddy@103 | 192 func pbkdf2sha256create(passphrase string, iters int) (result, salt string, err error) { |
| paddy@103 | 193 passBytes, saltBytes, err := pass.Create(sha256.New, iters, []byte(passphrase)) |
| paddy@103 | 194 if err != nil { |
| paddy@103 | 195 return "", "", err |
| paddy@103 | 196 } |
| paddy@103 | 197 result = hex.EncodeToString(passBytes) |
| paddy@103 | 198 salt = hex.EncodeToString(saltBytes) |
| paddy@103 | 199 return result, salt, err |
| paddy@98 | 200 } |
| paddy@98 | 201 |
| paddy@98 | 202 func pbkdf2sha256calc() (int, error) { |
| paddy@98 | 203 return pass.CalculateIterations(sha256.New) |
| paddy@98 | 204 } |
| paddy@98 | 205 |
| paddy@98 | 206 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@98 | 207 profile, err := context.GetProfileByLogin(user) |
| paddy@98 | 208 if err != nil { |
| paddy@98 | 209 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@98 | 210 return Profile{}, ErrIncorrectAuth |
| paddy@98 | 211 } |
| paddy@98 | 212 return Profile{}, err |
| paddy@98 | 213 } |
| paddy@98 | 214 if profile.Compromised { |
| paddy@98 | 215 return Profile{}, ErrProfileCompromised |
| paddy@98 | 216 } |
| paddy@98 | 217 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) { |
| paddy@98 | 218 return profile, ErrProfileLocked |
| paddy@98 | 219 } |
| paddy@98 | 220 scheme, ok := passphraseSchemes[profile.PassphraseScheme] |
| paddy@98 | 221 if !ok { |
| paddy@98 | 222 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@98 | 223 } |
| paddy@98 | 224 result, err := scheme.check(profile, passphrase) |
| paddy@98 | 225 if !result { |
| paddy@98 | 226 return Profile{}, err |
| paddy@98 | 227 } |
| paddy@98 | 228 return profile, nil |
| paddy@98 | 229 } |
| paddy@98 | 230 |
| paddy@98 | 231 // CreateSessionHandler allows the user to log into their account and create their session. |
| paddy@98 | 232 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@98 | 233 // BUG(paddy): Creating a session needs CSRF protection, right? This whole thing should get a security audit |
| paddy@98 | 234 errors := []error{} |
| paddy@98 | 235 if r.Method == "POST" { |
| paddy@98 | 236 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context) |
| paddy@98 | 237 if err == nil { |
| paddy@98 | 238 ip := r.Header.Get("X-Forwarded-For") |
| paddy@98 | 239 if ip == "" { |
| paddy@98 | 240 ip = r.RemoteAddr |
| paddy@98 | 241 } |
| paddy@98 | 242 session := Session{ |
| paddy@98 | 243 ID: uuid.NewID().String(), |
| paddy@98 | 244 IP: ip, |
| paddy@98 | 245 UserAgent: r.UserAgent(), |
| paddy@98 | 246 ProfileID: profile.ID, |
| paddy@98 | 247 Login: r.PostFormValue("login"), |
| paddy@98 | 248 Created: time.Now(), |
| paddy@98 | 249 Active: true, |
| paddy@98 | 250 } |
| paddy@98 | 251 err = context.CreateSession(session) |
| paddy@98 | 252 if err != nil { |
| paddy@98 | 253 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 254 w.Write([]byte(err.Error())) |
| paddy@98 | 255 return |
| paddy@98 | 256 } |
| paddy@98 | 257 // BUG(paddy): really need to do a security audit on our cookie |
| paddy@98 | 258 cookie := http.Cookie{ |
| paddy@98 | 259 Name: authCookieName, |
| paddy@98 | 260 Value: session.ID, |
| paddy@98 | 261 Expires: time.Now().Add(24 * 7 * time.Hour), |
| paddy@98 | 262 HttpOnly: true, |
| paddy@98 | 263 } |
| paddy@98 | 264 http.SetCookie(w, &cookie) |
| paddy@98 | 265 redirectTo := r.URL.Query().Get("from") |
| paddy@98 | 266 if redirectTo == "" { |
| paddy@98 | 267 redirectTo = "/" |
| paddy@98 | 268 } |
| paddy@98 | 269 http.Redirect(w, r, redirectTo, http.StatusFound) |
| paddy@98 | 270 return |
| paddy@98 | 271 } else if err != ErrIncorrectAuth && err != ErrProfileCompromised && err != ErrProfileLocked { |
| paddy@98 | 272 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 273 w.Write([]byte(err.Error())) |
| paddy@98 | 274 return |
| paddy@98 | 275 } else { |
| paddy@98 | 276 errors = append(errors, err) |
| paddy@98 | 277 } |
| paddy@98 | 278 } |
| paddy@98 | 279 context.Render(w, loginTemplateName, map[string]interface{}{ |
| paddy@98 | 280 "errors": errors, |
| paddy@98 | 281 }) |
| paddy@98 | 282 } |
| paddy@119 | 283 |
| paddy@119 | 284 func credentialsValidate(w http.ResponseWriter, r *http.Request, context Context) (scope string, profileID uuid.ID, valid bool) { |
| paddy@119 | 285 enc := json.NewEncoder(w) |
| paddy@119 | 286 username := r.PostFormValue("username") |
| paddy@119 | 287 password := r.PostFormValue("password") |
| paddy@119 | 288 scope = r.PostFormValue("scope") |
| paddy@119 | 289 profile, err := authenticate(username, password, context) |
| paddy@119 | 290 if err != nil { |
| paddy@119 | 291 if err == ErrIncorrectAuth || err == ErrProfileCompromised || err == ErrProfileLocked { |
| paddy@119 | 292 w.WriteHeader(http.StatusBadRequest) |
| paddy@119 | 293 renderJSONError(enc, "invalid_grant") |
| paddy@119 | 294 return |
| paddy@119 | 295 } |
| paddy@119 | 296 w.WriteHeader(http.StatusInternalServerError) |
| paddy@119 | 297 w.Write([]byte(err.Error())) |
| paddy@119 | 298 return |
| paddy@119 | 299 } |
| paddy@119 | 300 profileID = profile.ID |
| paddy@119 | 301 valid = true |
| paddy@119 | 302 return |
| paddy@119 | 303 } |