auth
auth/session.go
Add an endpoint to validate and register profiles. Add a newProfileRequest object that defines the user-specified properties of a new Profile. Add a helper that validates a newProfileRequest and modifies it for sanitization, mostly just removing leading and trailing whitespace. Add MaxNameLength, MaxUsernameLength, and MaxEmailLength constants to hold the maximum length for those properties. Add errors to be returned when a users attempts to log in with a profile that is compromised or locked. Add the bare bones of a CreateProfileHandler that validates a profile registration request adn uses it to create a Profile and at least one Login. Create a requestError struct that is used for returning API errors, along with constants for the slugs we'll use to signal those errors.
| paddy@70 | 1 package auth |
| paddy@70 | 2 |
| paddy@70 | 3 import ( |
| paddy@98 | 4 "crypto/sha256" |
| paddy@98 | 5 "encoding/hex" |
| paddy@70 | 6 "errors" |
| paddy@98 | 7 "log" |
| paddy@98 | 8 "net/http" |
| paddy@89 | 9 "sort" |
| paddy@70 | 10 "time" |
| paddy@70 | 11 |
| paddy@98 | 12 "code.secondbit.org/pass" |
| paddy@70 | 13 "code.secondbit.org/uuid" |
| paddy@98 | 14 |
| 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@70 | 22 var ( |
| paddy@70 | 23 // ErrNoSessionStore is returned when a Context tries to act on a sessionStore without setting on first. |
| paddy@70 | 24 ErrNoSessionStore = errors.New("no sessionStore was specified for the Context") |
| paddy@70 | 25 // ErrSessionNotFound is returned when a Session is requested but not found in the sessionStore. |
| paddy@70 | 26 ErrSessionNotFound = errors.New("session not found in sessionStore") |
| paddy@70 | 27 // ErrInvalidSession is returned when a Session is specified but is not valid. |
| paddy@70 | 28 ErrInvalidSession = errors.New("session is not valid") |
| paddy@77 | 29 // ErrSessionAlreadyExists is returned when a sessionStore tries to store a Session with an ID that already exists in the sessionStore. |
| paddy@77 | 30 ErrSessionAlreadyExists = errors.New("session already exists") |
| paddy@98 | 31 |
| paddy@98 | 32 passphraseSchemes = map[int]passphraseScheme{ |
| paddy@98 | 33 1: { |
| paddy@98 | 34 check: pbkdf2sha256check, |
| paddy@98 | 35 create: pbkdf2sha256create, |
| paddy@98 | 36 calculateIterations: pbkdf2sha256calc, |
| paddy@98 | 37 }, |
| paddy@98 | 38 } |
| paddy@70 | 39 ) |
| paddy@70 | 40 |
| paddy@98 | 41 type passphraseScheme struct { |
| paddy@98 | 42 check func(profile Profile, passphrase string) (bool, error) |
| paddy@98 | 43 create func(passphrase string, iterations int) (result, salt []byte, err error) |
| paddy@98 | 44 calculateIterations func() (int, error) |
| paddy@98 | 45 } |
| paddy@98 | 46 |
| paddy@70 | 47 // Session represents a user's authenticated session, associating it with a profile |
| paddy@70 | 48 // and some audit data. |
| paddy@70 | 49 type Session struct { |
| paddy@70 | 50 ID string |
| paddy@70 | 51 IP string |
| paddy@70 | 52 UserAgent string |
| paddy@70 | 53 ProfileID uuid.ID |
| paddy@98 | 54 Login string |
| paddy@70 | 55 Created time.Time |
| paddy@70 | 56 Active bool |
| paddy@70 | 57 } |
| paddy@70 | 58 |
| paddy@89 | 59 type sortedSessions []Session |
| paddy@89 | 60 |
| paddy@89 | 61 func (s sortedSessions) Len() int { |
| paddy@89 | 62 return len(s) |
| paddy@89 | 63 } |
| paddy@89 | 64 |
| paddy@89 | 65 func (s sortedSessions) Less(i, j int) bool { |
| paddy@89 | 66 return s[i].Created.After(s[j].Created) |
| paddy@89 | 67 } |
| paddy@89 | 68 |
| paddy@89 | 69 func (s sortedSessions) Swap(i, j int) { |
| paddy@89 | 70 s[i], s[j] = s[j], s[i] |
| paddy@89 | 71 } |
| paddy@89 | 72 |
| paddy@70 | 73 type sessionStore interface { |
| paddy@70 | 74 createSession(session Session) error |
| paddy@70 | 75 getSession(id string) (Session, error) |
| paddy@70 | 76 removeSession(id string) error |
| paddy@70 | 77 listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) |
| paddy@70 | 78 } |
| paddy@77 | 79 |
| paddy@77 | 80 func (m *memstore) createSession(session Session) error { |
| paddy@77 | 81 m.sessionLock.Lock() |
| paddy@77 | 82 defer m.sessionLock.Unlock() |
| paddy@77 | 83 if _, ok := m.sessions[session.ID]; ok { |
| paddy@77 | 84 return ErrSessionAlreadyExists |
| paddy@77 | 85 } |
| paddy@77 | 86 m.sessions[session.ID] = session |
| paddy@77 | 87 return nil |
| paddy@77 | 88 } |
| paddy@77 | 89 |
| paddy@77 | 90 func (m *memstore) getSession(id string) (Session, error) { |
| paddy@77 | 91 m.sessionLock.RLock() |
| paddy@77 | 92 defer m.sessionLock.RUnlock() |
| paddy@77 | 93 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 94 return Session{}, ErrSessionNotFound |
| paddy@77 | 95 } |
| paddy@77 | 96 return m.sessions[id], nil |
| paddy@77 | 97 } |
| paddy@77 | 98 |
| paddy@77 | 99 func (m *memstore) removeSession(id string) error { |
| paddy@77 | 100 m.sessionLock.Lock() |
| paddy@77 | 101 defer m.sessionLock.Unlock() |
| paddy@77 | 102 if _, ok := m.sessions[id]; !ok { |
| paddy@77 | 103 return ErrSessionNotFound |
| paddy@77 | 104 } |
| paddy@77 | 105 delete(m.sessions, id) |
| paddy@77 | 106 return nil |
| paddy@77 | 107 } |
| paddy@77 | 108 |
| paddy@77 | 109 func (m *memstore) listSessions(profile uuid.ID, before time.Time, num int64) ([]Session, error) { |
| paddy@77 | 110 m.sessionLock.RLock() |
| paddy@77 | 111 defer m.sessionLock.RUnlock() |
| paddy@77 | 112 res := []Session{} |
| paddy@77 | 113 for _, session := range m.sessions { |
| paddy@77 | 114 if int64(len(res)) >= num { |
| paddy@77 | 115 break |
| paddy@77 | 116 } |
| paddy@77 | 117 if profile != nil && !profile.Equal(session.ProfileID) { |
| paddy@77 | 118 continue |
| paddy@77 | 119 } |
| paddy@77 | 120 if !before.IsZero() && session.Created.After(before) { |
| paddy@77 | 121 continue |
| paddy@77 | 122 } |
| paddy@77 | 123 res = append(res, session) |
| paddy@77 | 124 } |
| paddy@89 | 125 sorted := sortedSessions(res) |
| paddy@89 | 126 sort.Sort(sorted) |
| paddy@89 | 127 res = []Session(sorted) |
| paddy@77 | 128 return res, nil |
| paddy@77 | 129 } |
| paddy@98 | 130 |
| paddy@98 | 131 // RegisterSessionHandlers adds handlers to the passed router to handle the session endpoints, like login and logout. |
| paddy@98 | 132 func RegisterSessionHandlers(r *mux.Router, context Context) { |
| paddy@98 | 133 r.Handle("/login", wrap(context, CreateSessionHandler)) |
| paddy@98 | 134 } |
| paddy@98 | 135 |
| paddy@98 | 136 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@98 | 137 cookie, err := r.Cookie(authCookieName) |
| paddy@98 | 138 if err == http.ErrNoCookie { |
| paddy@98 | 139 return Session{}, ErrNoSession |
| paddy@98 | 140 } else if err != nil { |
| paddy@98 | 141 log.Println(err) |
| paddy@98 | 142 return Session{}, err |
| paddy@98 | 143 } |
| paddy@98 | 144 sess, err := context.GetSession(cookie.Value) |
| paddy@98 | 145 if err == ErrSessionNotFound { |
| paddy@98 | 146 return Session{}, ErrInvalidSession |
| paddy@98 | 147 } else if err != nil { |
| paddy@98 | 148 return Session{}, err |
| paddy@98 | 149 } |
| paddy@98 | 150 if !sess.Active { |
| paddy@98 | 151 return Session{}, ErrInvalidSession |
| paddy@98 | 152 } |
| paddy@98 | 153 return sess, nil |
| paddy@98 | 154 } |
| paddy@98 | 155 |
| paddy@98 | 156 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@98 | 157 if context.loginURI == nil { |
| paddy@98 | 158 return "" |
| paddy@98 | 159 } |
| paddy@98 | 160 uri := *context.loginURI |
| paddy@98 | 161 q := uri.Query() |
| paddy@98 | 162 q.Set("from", r.URL.String()) |
| paddy@98 | 163 uri.RawQuery = q.Encode() |
| paddy@98 | 164 return uri.String() |
| paddy@98 | 165 } |
| paddy@98 | 166 |
| paddy@98 | 167 func pbkdf2sha256check(profile Profile, passphrase string) (bool, error) { |
| paddy@98 | 168 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@98 | 169 if err != nil { |
| paddy@98 | 170 return false, err |
| paddy@98 | 171 } |
| paddy@98 | 172 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(profile.Salt)) |
| paddy@98 | 173 if !pass.Compare(candidate, realPass) { |
| paddy@98 | 174 return false, ErrIncorrectAuth |
| paddy@98 | 175 } |
| paddy@98 | 176 return true, nil |
| paddy@98 | 177 } |
| paddy@98 | 178 |
| paddy@98 | 179 func pbkdf2sha256create(passphrase string, iters int) (result, salt []byte, err error) { |
| paddy@98 | 180 return pass.Create(sha256.New, iters, []byte(passphrase)) |
| paddy@98 | 181 } |
| paddy@98 | 182 |
| paddy@98 | 183 func pbkdf2sha256calc() (int, error) { |
| paddy@98 | 184 return pass.CalculateIterations(sha256.New) |
| paddy@98 | 185 } |
| paddy@98 | 186 |
| paddy@98 | 187 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@98 | 188 profile, err := context.GetProfileByLogin(user) |
| paddy@98 | 189 if err != nil { |
| paddy@98 | 190 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@98 | 191 return Profile{}, ErrIncorrectAuth |
| paddy@98 | 192 } |
| paddy@98 | 193 return Profile{}, err |
| paddy@98 | 194 } |
| paddy@98 | 195 if profile.Compromised { |
| paddy@98 | 196 return Profile{}, ErrProfileCompromised |
| paddy@98 | 197 } |
| paddy@98 | 198 if !profile.LockedUntil.IsZero() && profile.LockedUntil.After(time.Now()) { |
| paddy@98 | 199 return profile, ErrProfileLocked |
| paddy@98 | 200 } |
| paddy@98 | 201 scheme, ok := passphraseSchemes[profile.PassphraseScheme] |
| paddy@98 | 202 if !ok { |
| paddy@98 | 203 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@98 | 204 } |
| paddy@98 | 205 result, err := scheme.check(profile, passphrase) |
| paddy@98 | 206 if !result { |
| paddy@98 | 207 return Profile{}, err |
| paddy@98 | 208 } |
| paddy@98 | 209 return profile, nil |
| paddy@98 | 210 } |
| paddy@98 | 211 |
| paddy@98 | 212 // CreateSessionHandler allows the user to log into their account and create their session. |
| paddy@98 | 213 func CreateSessionHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@98 | 214 // BUG(paddy): Creating a session needs CSRF protection, right? This whole thing should get a security audit |
| paddy@98 | 215 errors := []error{} |
| paddy@98 | 216 if r.Method == "POST" { |
| paddy@98 | 217 profile, err := authenticate(r.PostFormValue("login"), r.PostFormValue("passphrase"), context) |
| paddy@98 | 218 if err == nil { |
| paddy@98 | 219 ip := r.Header.Get("X-Forwarded-For") |
| paddy@98 | 220 if ip == "" { |
| paddy@98 | 221 ip = r.RemoteAddr |
| paddy@98 | 222 } |
| paddy@98 | 223 session := Session{ |
| paddy@98 | 224 ID: uuid.NewID().String(), |
| paddy@98 | 225 IP: ip, |
| paddy@98 | 226 UserAgent: r.UserAgent(), |
| paddy@98 | 227 ProfileID: profile.ID, |
| paddy@98 | 228 Login: r.PostFormValue("login"), |
| paddy@98 | 229 Created: time.Now(), |
| paddy@98 | 230 Active: true, |
| paddy@98 | 231 } |
| paddy@98 | 232 err = context.CreateSession(session) |
| paddy@98 | 233 if err != nil { |
| paddy@98 | 234 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 235 w.Write([]byte(err.Error())) |
| paddy@98 | 236 return |
| paddy@98 | 237 } |
| paddy@98 | 238 // BUG(paddy): really need to do a security audit on our cookie |
| paddy@98 | 239 cookie := http.Cookie{ |
| paddy@98 | 240 Name: authCookieName, |
| paddy@98 | 241 Value: session.ID, |
| paddy@98 | 242 Expires: time.Now().Add(24 * 7 * time.Hour), |
| paddy@98 | 243 HttpOnly: true, |
| paddy@98 | 244 } |
| paddy@98 | 245 http.SetCookie(w, &cookie) |
| paddy@98 | 246 redirectTo := r.URL.Query().Get("from") |
| paddy@98 | 247 if redirectTo == "" { |
| paddy@98 | 248 redirectTo = "/" |
| paddy@98 | 249 } |
| paddy@98 | 250 http.Redirect(w, r, redirectTo, http.StatusFound) |
| paddy@98 | 251 return |
| paddy@98 | 252 } else if err != ErrIncorrectAuth && err != ErrProfileCompromised && err != ErrProfileLocked { |
| paddy@98 | 253 w.WriteHeader(http.StatusInternalServerError) |
| paddy@98 | 254 w.Write([]byte(err.Error())) |
| paddy@98 | 255 return |
| paddy@98 | 256 } else { |
| paddy@98 | 257 errors = append(errors, err) |
| paddy@98 | 258 } |
| paddy@98 | 259 } |
| paddy@98 | 260 context.Render(w, loginTemplateName, map[string]interface{}{ |
| paddy@98 | 261 "errors": errors, |
| paddy@98 | 262 }) |
| paddy@98 | 263 } |