Mercurial > gemma
comparison controllers/pwreset.go @ 321:974a5e4c0055
Persist password reset requests in database.
author | Sascha L. Teichmann <sascha.teichmann@intevation.de> |
---|---|
date | Thu, 02 Aug 2018 16:40:14 +0200 |
parents | ac760b0f22a9 |
children | 34ecfd8dc11e |
comparison
equal
deleted
inserted
replaced
320:e4bf72cda62e | 321:974a5e4c0055 |
---|---|
8 "log" | 8 "log" |
9 "math/big" | 9 "math/big" |
10 "net/http" | 10 "net/http" |
11 "os/exec" | 11 "os/exec" |
12 "strings" | 12 "strings" |
13 "sync" | |
14 "text/template" | 13 "text/template" |
15 "time" | 14 "time" |
16 | 15 |
17 "gemma.intevation.de/gemma/auth" | 16 "gemma.intevation.de/gemma/auth" |
18 "gemma.intevation.de/gemma/config" | 17 "gemma.intevation.de/gemma/config" |
20 | 19 |
21 gomail "gopkg.in/gomail.v2" | 20 gomail "gopkg.in/gomail.v2" |
22 ) | 21 ) |
23 | 22 |
24 const ( | 23 const ( |
24 insertRequestSQL = `INSERT INTO pw_reset.password_reset_requests | |
25 (hash, username) VALUES ($1, $2)` | |
26 | |
27 countRequestsSQL = `SELECT count(*) FROM pw_reset.password_reset_requests` | |
28 | |
29 countRequestsUserSQL = `SELECT count(*) FROM pw_reset.password_reset_requests | |
30 WHERE username = $1` | |
31 | |
32 deleteRequestSQL = `DELETE FROM pw_reset.password_reset_requests | |
33 WHERE hash = $1` | |
34 | |
35 findRequestSQL = `SELECT lu.email_address, lu.username | |
36 FROM pw_reset.password_reset_requests prr | |
37 JOIN pw_reset.list_users lu on prr.username = lu.username | |
38 WHERE prr.hash = $1` | |
39 | |
40 cleanupRequestsSQL = `DELETE FROM pw_reset.password_reset_requests | |
41 WHERE issued < $1` | |
42 | |
25 userExistsSQL = `SELECT email_address | 43 userExistsSQL = `SELECT email_address |
26 FROM pw_reset.list_users WHERE username = $1` | 44 FROM pw_reset.list_users WHERE username = $1` |
27 | 45 |
28 updatePasswordSQL = `UPDATE pw_reset.list_users | 46 updatePasswordSQL = `UPDATE pw_reset.list_users |
29 SET pw = $1 WHERE username = $2` | 47 SET pw = $1 WHERE username = $2` |
30 ) | 48 ) |
31 | 49 |
32 const ( | 50 const ( |
33 passwordResetValid = time.Hour | 51 hashLength = 16 |
52 passwordLength = 20 | |
53 passwordResetValid = 12 * time.Hour | |
34 maxPasswordResets = 1000 | 54 maxPasswordResets = 1000 |
35 maxPasswordRequestsPerUser = 5 | 55 maxPasswordRequestsPerUser = 5 |
56 cleanupPause = 15 * time.Minute | |
36 ) | 57 ) |
37 | 58 |
38 var ( | 59 var ( |
39 passwordResetRequestMailTmpl = template.Must( | 60 passwordResetRequestMailTmpl = template.Must( |
40 template.New("request").Parse(`You have requested a password change | 61 template.New("request").Parse(`You have requested a password change |
43 | 64 |
44 Please follow this link to get to the page where you can change your password. | 65 Please follow this link to get to the page where you can change your password. |
45 | 66 |
46 {{ .HTTPS }}://{{ .Server }}/api/users/passwordreset/{{ .Hash }} | 67 {{ .HTTPS }}://{{ .Server }}/api/users/passwordreset/{{ .Hash }} |
47 | 68 |
48 The link is only valid for one hour. | 69 The link is only valid for 12 hours. |
49 | 70 |
50 Best regards | 71 Best regards |
51 Your service team`)) | 72 Your service team`)) |
52 | 73 |
53 passwordResetMailTmpl = template.Must( | 74 passwordResetMailTmpl = template.Must( |
61 | 82 |
62 Best regards | 83 Best regards |
63 Your service team`)) | 84 Your service team`)) |
64 ) | 85 ) |
65 | 86 |
66 type timedUser struct { | 87 func asServiceUser(fn func(*sql.DB) error) error { |
67 user string | 88 cfg := &config.Config |
68 email string | 89 db, err := auth.OpenDB(cfg.ServiceUser, cfg.ServicePassword) |
69 time time.Time | 90 if err == nil { |
70 } | 91 defer db.Close() |
71 | 92 err = fn(db) |
72 type resetRequests struct { | 93 } |
73 sync.Mutex | 94 return err |
74 reqs map[string]*timedUser | 95 } |
75 } | 96 |
76 | 97 func init() { |
77 var passwordResetRequests = func() *resetRequests { | 98 go removeOutdated() |
78 rr := &resetRequests{reqs: map[string]*timedUser{}} | 99 } |
79 go func() { | 100 |
80 for { | 101 func removeOutdated() { |
81 time.Sleep(2 * time.Minute) | 102 for { |
82 rr.removeOutdated() | 103 time.Sleep(cleanupPause) |
83 } | 104 err := asServiceUser(func(db *sql.DB) error { |
84 }() | 105 good := time.Now().Add(-passwordResetValid) |
85 return rr | 106 _, err := db.Exec(cleanupRequestsSQL, good) |
86 }() | 107 return err |
87 | 108 }) |
88 func (rr *resetRequests) len() int { | 109 if err != nil { |
89 rr.Lock() | 110 log.Printf("error: %v\n", err) |
90 l := len(rr.reqs) | |
91 rr.Unlock() | |
92 return l | |
93 } | |
94 | |
95 func (rr *resetRequests) userAllowed(user string) bool { | |
96 rr.Lock() | |
97 defer rr.Unlock() | |
98 var count int | |
99 for _, v := range rr.reqs { | |
100 if v.user == user { | |
101 if count++; count >= maxPasswordRequestsPerUser { | |
102 return false | |
103 } | |
104 } | |
105 } | |
106 return true | |
107 } | |
108 | |
109 func (rr *resetRequests) store(hash, user, email string) { | |
110 now := time.Now() | |
111 rr.Lock() | |
112 rr.reqs[hash] = &timedUser{user, email, now} | |
113 rr.Unlock() | |
114 } | |
115 | |
116 func (rr *resetRequests) fetch(hash string) *timedUser { | |
117 rr.Lock() | |
118 defer rr.Unlock() | |
119 tu := rr.reqs[hash] | |
120 if tu == nil { | |
121 return nil | |
122 } | |
123 if tu.time.Before(time.Now().Add(-passwordResetValid)) { | |
124 delete(rr.reqs, hash) | |
125 return nil | |
126 } | |
127 return tu | |
128 } | |
129 | |
130 func (rr *resetRequests) delete(hash string) { | |
131 rr.Lock() | |
132 delete(rr.reqs, hash) | |
133 rr.Unlock() | |
134 } | |
135 | |
136 func (rr *resetRequests) removeOutdated() { | |
137 good := time.Now().Add(-passwordResetValid) | |
138 rr.Lock() | |
139 defer rr.Unlock() | |
140 for k, v := range rr.reqs { | |
141 if v.time.Before(good) { | |
142 delete(rr.reqs, k) | |
143 } | 111 } |
144 } | 112 } |
145 } | 113 } |
146 | 114 |
147 func requestMessageBody(https, user, hash, server string) string { | 115 func requestMessageBody(https, user, hash, server string) string { |
187 return "https" | 155 return "https" |
188 } | 156 } |
189 return "http" | 157 return "http" |
190 } | 158 } |
191 | 159 |
192 const ( | |
193 hashLength = 32 | |
194 passwordLength = 20 | |
195 ) | |
196 | |
197 func generateHash() string { | 160 func generateHash() string { |
198 return hex.EncodeToString(auth.GenerateRandomKey(hashLength)) | 161 return hex.EncodeToString(auth.GenerateRandomKey(hashLength)) |
199 } | 162 } |
200 | 163 |
201 func randomString(n int) string { | 164 func randomString(n int) string { |
223 // First try pwgen | 186 // First try pwgen |
224 out, err := exec.Command("pwgen", "-y", "20", "1").Output() | 187 out, err := exec.Command("pwgen", "-y", "20", "1").Output() |
225 if err == nil { | 188 if err == nil { |
226 return strings.TrimSpace(string(out)) | 189 return strings.TrimSpace(string(out)) |
227 } | 190 } |
228 | |
229 // Use internal generator. | 191 // Use internal generator. |
230 return randomString(20) | 192 return randomString(20) |
231 | |
232 } | 193 } |
233 | 194 |
234 func sendMail(email, subject, body string) error { | 195 func sendMail(email, subject, body string) error { |
235 cfg := &config.Config | 196 cfg := &config.Config |
236 m := gomail.NewMessage() | 197 m := gomail.NewMessage() |
252 } | 213 } |
253 | 214 |
254 func passwordResetRequest( | 215 func passwordResetRequest( |
255 input interface{}, | 216 input interface{}, |
256 req *http.Request, | 217 req *http.Request, |
257 db *sql.DB, | 218 _ *sql.DB, |
258 ) (jr JSONResult, err error) { | 219 ) (jr JSONResult, err error) { |
259 | |
260 // Limit total number of password requests. | |
261 if passwordResetRequests.len() >= maxPasswordResets { | |
262 err = JSONError{ | |
263 Code: http.StatusServiceUnavailable, | |
264 Message: "Too much password reset request", | |
265 } | |
266 return | |
267 } | |
268 | 220 |
269 user := input.(*PWResetUser) | 221 user := input.(*PWResetUser) |
270 | 222 |
271 if user.User == "" { | 223 if user.User == "" { |
272 err = JSONError{http.StatusBadRequest, "Invalid user name"} | 224 err = JSONError{http.StatusBadRequest, "Invalid user name"} |
273 return | 225 return |
274 } | 226 } |
275 | 227 |
276 cfg := &config.Config | 228 var hash, email string |
277 if db, err = auth.OpenDB(cfg.ServiceUser, cfg.ServicePassword); err != nil { | 229 |
278 return | 230 if err = asServiceUser(func(db *sql.DB) error { |
279 } | 231 |
280 defer db.Close() | 232 var count int64 |
281 | 233 if err := db.QueryRow(countRequestsSQL).Scan(&count); err != nil { |
282 var email string | 234 return err |
283 err = db.QueryRow(userExistsSQL, user.User).Scan(&email) | 235 } |
284 | 236 |
285 switch { | 237 // Limit total number of password requests. |
286 case err == sql.ErrNoRows: | 238 if count >= maxPasswordResets { |
287 err = JSONError{http.StatusNotFound, "User does not exist."} | 239 return JSONError{ |
288 return | 240 Code: http.StatusServiceUnavailable, |
289 case err != nil: | 241 Message: "Too much password reset request", |
290 return | 242 } |
291 } | 243 } |
292 | 244 |
293 // Limit requests per user | 245 err := db.QueryRow(userExistsSQL, user.User).Scan(&email) |
294 if !passwordResetRequests.userAllowed(user.User) { | 246 |
295 err = JSONError{ | 247 switch { |
296 Code: http.StatusServiceUnavailable, | 248 case err == sql.ErrNoRows: |
297 Message: "Too much password reset requests for user", | 249 return JSONError{http.StatusNotFound, "User does not exist."} |
298 } | 250 case err != nil: |
299 return | 251 return err |
300 } | 252 } |
301 | 253 |
302 hash := generateHash() | 254 if err := db.QueryRow(countRequestsUserSQL, user.User).Scan(&count); err != nil { |
303 | 255 return err |
304 passwordResetRequests.store(hash, user.User, email) | 256 } |
305 | 257 |
306 body := requestMessageBody(useHTTPS(req), user.User, hash, req.Host) | 258 // Limit requests per user |
307 | 259 if count >= maxPasswordRequestsPerUser { |
308 if err = sendMail(email, "Password Reset Link", body); err != nil { | 260 return JSONError{ |
309 return | 261 Code: http.StatusServiceUnavailable, |
310 } | 262 Message: "Too much password reset requests for user", |
311 | 263 } |
312 jr.Result = &struct { | 264 } |
313 SendTo string `json:"send-to"` | 265 |
314 }{email} | 266 hash = generateHash() |
315 | 267 _, err = db.Exec(insertRequestSQL, hash, user.User) |
268 return err | |
269 }); err == nil { | |
270 body := requestMessageBody(useHTTPS(req), user.User, hash, req.Host) | |
271 | |
272 if err = sendMail(email, "Password Reset Link", body); err == nil { | |
273 jr.Result = &struct { | |
274 SendTo string `json:"send-to"` | |
275 }{email} | |
276 } | |
277 } | |
316 return | 278 return |
317 } | 279 } |
318 | 280 |
319 func passwordReset( | 281 func passwordReset( |
320 _ interface{}, | 282 _ interface{}, |
321 req *http.Request, | 283 req *http.Request, |
322 db *sql.DB, | 284 _ *sql.DB, |
323 ) (jr JSONResult, err error) { | 285 ) (jr JSONResult, err error) { |
324 | 286 |
325 hash := mux.Vars(req)["hash"] | 287 hash := mux.Vars(req)["hash"] |
326 if _, err = hex.DecodeString(hash); err != nil { | 288 if _, err = hex.DecodeString(hash); err != nil { |
327 err = JSONError{http.StatusBadRequest, "Invalid hash"} | 289 err = JSONError{http.StatusBadRequest, "Invalid hash"} |
328 return | 290 return |
329 } | 291 } |
330 | 292 |
331 tu := passwordResetRequests.fetch(hash) | 293 var email, user, password string |
332 if tu == nil { | 294 |
333 err = JSONError{http.StatusNotFound, "No such hash"} | 295 if err = asServiceUser(func(db *sql.DB) error { |
334 return | 296 err := db.QueryRow(findRequestSQL, hash).Scan(&email, &user) |
335 } | 297 switch { |
336 | 298 case err == sql.ErrNoRows: |
337 password := generateNewPassword() | 299 return JSONError{http.StatusNotFound, "No such hash"} |
338 | 300 case err != nil: |
339 cfg := &config.Config | 301 return err |
340 if db, err = auth.OpenDB(cfg.ServiceUser, cfg.ServicePassword); err != nil { | 302 } |
341 return | 303 password = generateNewPassword() |
342 } | 304 res, err := db.Exec(updatePasswordSQL, password, user) |
343 defer db.Close() | 305 if err != nil { |
344 | 306 return err |
345 var res sql.Result | 307 } |
346 if res, err = db.Exec( | 308 if n, err2 := res.RowsAffected(); err2 == nil && n == 0 { |
347 updatePasswordSQL, | 309 return JSONError{http.StatusNotFound, "User not found"} |
348 password, | 310 } |
349 tu.user, | 311 _, err = db.Exec(deleteRequestSQL, hash) |
350 ); err != nil { | 312 return err |
351 return | 313 }); err == nil { |
352 } | 314 body := changedMessageBody(useHTTPS(req), user, password, req.Host) |
353 | 315 if err = sendMail(email, "Password Reset Done", body); err == nil { |
354 passwordResetRequests.delete(hash) | 316 jr.Result = &struct { |
355 | 317 SendTo string `json:"send-to"` |
356 if n, err2 := res.RowsAffected(); err2 == nil && n == 0 { | 318 }{email} |
357 err = JSONError{http.StatusNotFound, "User not found"} | 319 } |
358 return | 320 } |
359 } | |
360 | |
361 body := changedMessageBody(useHTTPS(req), tu.user, password, req.Host) | |
362 if err = sendMail(tu.email, "Password Reset Done", body); err != nil { | |
363 return | |
364 } | |
365 | |
366 jr.Result = &struct { | |
367 Message string `json:"message"` | |
368 }{"User password changed"} | |
369 | |
370 return | 321 return |
371 } | 322 } |